Identity & multi-tenant
Identity & multi-tenant
Périmètre : authentification (email/mot de passe, magic link, MFA TOTP), organisations (multi-tenant), membres & équipes, RBAC (rôles + permissions), API keys, audit log, RGPD.
/account/* et /workspace/admin/*. Les routes API sont dans /api/account/* et /api/workspace/*. Sources : docs/06-domain-identity.md et docs/05-auth-rbac.md.Vocabulaire & entités
| Terme | Entité | Définition |
|---|---|---|
| Utilisateur | User | Personne physique pouvant se connecter, identifiée par un email unique global. |
| Organisation | Organization | Tenant Pulse (org cliente, bailleur, partenaire). Créée uniquement par SUPERADMIN_PROVIDER. |
| Membre | Membership | Lien User ↔ Organization avec un rôle. Un user peut appartenir à plusieurs orgs. |
| Équipe | Team / TeamMembership | Sous-groupe au sein d'une organisation, avec un leader désigné. |
| Rôle | Role | Ensemble de permissions. Système (isSystem = true) ou custom (hérite d'un rôle standard). |
| Permission | Permission / RolePermission | Granule domain.entity:action, vérifiée API et UI. |
| Invitation | Invitation | Token signé, expire 7 jours ; crée un Membership à l'acceptation. |
| API key | ApiKey | Token pk_live_… (hash bcrypt) pour intégrations M2M, scopes restrictifs, expiration 90 j. |
| Session | Session | Cookie HttpOnly Secure SameSite=Lax, durée 30 j glissants, expiration absolue 90 j. |
| Org active | session.activeOrganizationId | ID lu depuis la session ; toutes les requêtes API filtrent Prisma sur cet ID. |
Le schéma Prisma complet est dans
docs/04-data-model.md§3. Les tables techniques Better Auth (Account,Verification,TwoFactor) ne sont pas exposées directement.
Écrans
Espace personnel (/account)
| Écran | Route | Contenu |
|---|---|---|
| Profil | /account/profile | Avatar, nom, prénom, locale, fuseau, téléphone. |
| Sécurité | /account/security | Changer mot de passe, MFA TOTP (activer/régénérer), sessions actives (révocation), historique 30 connexions. |
| Préférences | /account/preferences | Notifications par type × canal, thème clair/sombre/system, densité. |
| API Keys | /account/api-keys | Créer/révoquer ses propres clés, voir lastUsedAt. |
| Export RGPD | /account/data-export | Zip JSON de toutes ses données + demande de suppression de compte. |
| Organisations | /account/organizations | Liste des orgs dont je suis membre, switch d'org active. |
Administration d'organisation (/workspace/admin, rôle ADMIN)
| Écran | Route | Contenu |
|---|---|---|
| Utilisateurs | /workspace/admin/users | Liste + filtres + invitation + désactivation + reset password. |
| Fiche user | /workspace/admin/users/[id] | Rôle, équipes, dernière connexion, audit log filtré. |
| Équipes | /workspace/admin/teams | CRUD équipes, membres, leader. |
| Rôles | /workspace/admin/roles | Rôles existants, création de rôles custom, édition matrice permissions. |
| API Keys org | /workspace/admin/api-keys | Clés au nom de l'organisation. |
| Paramètres | /workspace/admin/settings/general | Nom, logo, locale par défaut, fuseau. |
| Entités facturation | /workspace/admin/settings/billing | BillingEntity : SIRET, IBAN, séquences de numérotation, en-tête PDF. |
| Audit log | /workspace/admin/audit | Journal org, filtres acteur/action/période, export CSV. |
Modèle de données (points clés)
Membershipest unique sur(userId, organizationId)— doublons rejetés.Role.isSystem = true→ lecture seule (seed uniquement, pas d'édition en prod sauf super-admin).ApiKey: seul le préfixe est stocké en clair, le reste est hashé bcrypt.User.anonymizedAt: champ de traçabilité RGPD ; l'audit log est conservé intact même après anonymisation.User.cguVersion/cguAcceptedAt: re-acceptation forcée à la prochaine connexion après changement de version CGU.
Pile d'authentification — Better Auth
Better Auth 1.4+ est la source unique d'authentification (server/lib/auth.ts).
Plugins actifs
organization— multi-tenant natif, org active en sessiontwoFactor— MFA TOTP (Google Authenticator, Bitwarden…)magicLink— lien email 15 min, cas client externeadmin— impersonation, rôles globaux Pulse
Session
Cookie HttpOnly Secure SameSite=Lax. Durée 30 j glissants, expiration absolue 90 j (re-auth obligatoire). Stockée en DB — invalidation immédiate possible.
Politique mot de passe
Min 12 caractères (maj + min + chiffre + spécial). Pas de réutilisation des 5 derniers. Vérification HaveIBeenPwned côté serveur. Verrouillage après 5 échecs consécutifs (15 min).
Endpoints Better Auth (consommés via useAuthClient()) :
POST /api/auth/sign-in/email
POST /api/auth/sign-out
POST /api/auth/forget-password
POST /api/auth/reset-password
POST /api/auth/verify-email
POST /api/auth/magic-link/send
POST /api/auth/two-factor/enable · /verify
POST /api/auth/organization/set-active
POST /api/auth/admin/impersonate · /stop-impersonating
Modèle de rôles (RBAC)
Niveaux
Deux niveaux distincts : rôles globaux Pulse (transverses, hors org) et rôles métier (par Membership).
| Rôle global | Description |
|---|---|
SUPERADMIN_PROVIDER | Tout pouvoir : créer/supprimer orgs, impersonifier, console super-admin. |
ADMIN_PROVIDER | Support/DSI Pulse : consultation + impersonation lecture seule, pas de création/suppression d'org. |
Rôles métier standard livrés en seed : ADMIN, MANAGER, COMMERCIAL, SALES_BACK, ACCOUNTANT, LEASE_OFFICER, FINANCE_OFFICER, TECHNICIAN, SERVICE_MANAGER, LOGISTICS, CLIENT_USER, CLIENT_ADMIN, LENDER_USER, PARTNER_USER, SUPPLIER_USER, AUDITOR.
Format des permissions
domain.entity:action — exemples : identity.user:invite, lease.contract:sign, crm.opportunity:read.
Matrice rôles métier (extrait)
| Permission | ADMIN | MANAGER | COMMERCIAL | ACCOUNTANT | LEASE_OFFICER | TECHNICIAN | CLIENT_USER | AUDITOR |
|---|---|---|---|---|---|---|---|---|
identity.user:invite | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
crm.opportunity:* | ✅ | R | R/W | R | ❌ | ❌ | ❌ | R |
sales.invoice:* | ✅ | R | R | R/W | R | ❌ | R | R |
lease.contract:sign | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
lease.contract:unlock | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
asset:* | ✅ | R | R | R | R/W | R/W | R | R |
ai.workflow:run | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
Légende : ✅ = oui, R = lecture seule, R/W = lecture + écriture, ❌ = aucun accès. Matrice modifiable par l'admin d'organisation.
API
| Méthode | Route | Permission |
|---|---|---|
GET | /api/account/me | (authentifié) |
PATCH | /api/account/me | (authentifié) |
GET | /api/account/sessions | (authentifié) |
DELETE | /api/account/sessions/[id] | (authentifié) |
GET / POST | /api/account/api-keys | (authentifié) |
DELETE | /api/account/api-keys/[id] | (authentifié) |
GET | /api/account/data-export | (authentifié) |
POST | /api/account/delete-request | (authentifié) |
GET | /api/workspace/users | identity.user:read |
POST | /api/workspace/users/invite | identity.user:invite |
PATCH | /api/workspace/users/[id] | identity.user:update |
POST | /api/workspace/users/[id]/suspend | identity.user:update |
GET / POST | /api/workspace/teams | identity.user:read / :invite |
GET | /api/workspace/roles | identity.permission:read |
POST / PATCH / DELETE | /api/workspace/roles/[id] | identity.role:manage |
/api/workspace/users/inviteAuth Invite un utilisateur dans l'organisation active. Crée une Invitation (token signé, expire 7 j) et envoie l'email. Retourne 409 si l'email est déjà membre.
Corps (JSON)
COMMERCIAL, ACCOUNTANT, etc.).Requête
curl -s -X POST "$API/api/workspace/users/invite" \
-H "Content-Type: application/json" -H "Cookie: $SESSION" \
-d '{"email":"alice@example.com","roleCode":"COMMERCIAL"}'
Réponse
{ "id": "inv_…", "email": "alice@example.com", "expiresAt": "2026-06-23T10:00:00Z" }
Workflows
Invitation d'un utilisateur
Admin saisit email + rôle
→ Vérif unicité Membership (409 si doublon)
→ Création Invitation (token signé, 7 jours)
→ Email envoyé à l'invité
→ Invité clique → /auth/invitation?token=…
→ Setup compte (ou login si existant) + acceptation CGU
→ Membership créé · Invitation.acceptedAt rempli
→ Notification in-app à l'inviteur
Connexion standard
Email + mot de passe
→ Better Auth valide
→ MFA TOTP si activé
→ Session créée (cookie) · lastLoginAt/Ip mis à jour
→ 1 org → org active auto
→ N orgs → écran sélection org
→ Redirect dashboard (selon rôle)
Magic link
/auth/magic-link → email saisi
→ Lien signé (15 min) envoyé
→ Clic → session ouverte directement
→ MFA toujours exigée si activée
Switch d'organisation
Clic OrganizationSwitcher (topbar)
→ POST /api/auth/organization/set-active { organizationId }
→ Cookie session mis à jour
→ Refresh page (toutes les requêtes API utilisent le nouvel activeOrganizationId)
Désactivation d'un utilisateur
Admin → User.status = SUSPENDED
→ Sessions actives invalidées immédiatement
→ Connexion impossible
→ Historique conservé (factures, contrats, audit)
→ Réactivation possible
Anonymisation RGPD
Demande via /account/data-export ou par admin
→ Période de grâce 30 jours (annulable)
→ Anonymisation (pas suppression) :
email → anonymized-<id>@deleted.pulsegroup.fr
name/firstName/lastName → "Utilisateur supprimé"
phone → null · image → null
User.anonymizedAt = now()
→ Sessions invalidées
→ Audit log conservé tel quel (obligation légale)
Règles métier
- Unicité email globale (Better Auth) — un email ne peut appartenir qu'à un seul
User. - Unicité Membership sur
(userId, organizationId)— un user a un seul rôle par org. - Au moins 1 ADMIN actif par organisation : impossible de supprimer ou désactiver le dernier ADMIN.
- Rôles système non éditables :
Role.isSystem = trueest lecture seule (sauf super-admin). - Invitation expirée : token rejeté avec message clair, possibilité de renvoyer.
- Suspension : sessions invalidées, audit log conservé.
- Multi-tenant strict :
activeOrganizationIdlu exclusivement depuis la session — jamais depuis le body. Pas de fallback : sans org active, 401. Accès cross-tenant → 404 (pas 403, pour ne pas confirmer l'existence). - Impersonation : uniquement
SUPERADMIN_PROVIDER(full) etADMIN_PROVIDER(lecture seule). Bandeau rouge persistant. Session d'impersonation limitée à 1 heure. Notification email au user impersonifié dans les 24 h. lease.contract:unlock: raison textuelle obligatoire (min 20 caractères), audit log dédié, notification super-admin, re-verrouillage automatique sous 24 h.
Audit log
Systématique sur toute mutation sensible via auditLog(…) dans defineApiHandler. Rétention 10 ans (archive S3 Object Lock Compliance). Consultable sur 12 mois depuis l'app ; au-delà, restauration sur demande.
| Événement | Canal | Destinataire |
|---|---|---|
| Invitation reçue | Invité | |
| Invitation acceptée | In-app + Email | Inviteur |
| Mot de passe changé | User concerné | |
| Nouvelle session appareil inconnu | Email + In-app | User concerné |
| 5 échecs login → verrouillage | User concerné | |
| Compte suspendu | User concerné | |
| Impersonation effectuée | User impersonifié (J+1 max) | |
| API key proche expiration (J-7) | In-app + Email | User |