Identity & multi-tenant

Référence technique du domaine Identity — authentification Better Auth, organisations multi-tenant, membres, rôles et RBAC.

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.

Les écrans utilisateur vivent sous /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

TermeEntitéDéfinition
UtilisateurUserPersonne physique pouvant se connecter, identifiée par un email unique global.
OrganisationOrganizationTenant Pulse (org cliente, bailleur, partenaire). Créée uniquement par SUPERADMIN_PROVIDER.
MembreMembershipLien UserOrganization avec un rôle. Un user peut appartenir à plusieurs orgs.
ÉquipeTeam / TeamMembershipSous-groupe au sein d'une organisation, avec un leader désigné.
RôleRoleEnsemble de permissions. Système (isSystem = true) ou custom (hérite d'un rôle standard).
PermissionPermission / RolePermissionGranule domain.entity:action, vérifiée API et UI.
InvitationInvitationToken signé, expire 7 jours ; crée un Membership à l'acceptation.
API keyApiKeyToken pk_live_… (hash bcrypt) pour intégrations M2M, scopes restrictifs, expiration 90 j.
SessionSessionCookie HttpOnly Secure SameSite=Lax, durée 30 j glissants, expiration absolue 90 j.
Org activesession.activeOrganizationIdID 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)

ÉcranRouteContenu
Profil/account/profileAvatar, nom, prénom, locale, fuseau, téléphone.
Sécurité/account/securityChanger mot de passe, MFA TOTP (activer/régénérer), sessions actives (révocation), historique 30 connexions.
Préférences/account/preferencesNotifications par type × canal, thème clair/sombre/system, densité.
API Keys/account/api-keysCréer/révoquer ses propres clés, voir lastUsedAt.
Export RGPD/account/data-exportZip JSON de toutes ses données + demande de suppression de compte.
Organisations/account/organizationsListe des orgs dont je suis membre, switch d'org active.

Administration d'organisation (/workspace/admin, rôle ADMIN)

ÉcranRouteContenu
Utilisateurs/workspace/admin/usersListe + filtres + invitation + désactivation + reset password.
Fiche user/workspace/admin/users/[id]Rôle, équipes, dernière connexion, audit log filtré.
Équipes/workspace/admin/teamsCRUD équipes, membres, leader.
Rôles/workspace/admin/rolesRôles existants, création de rôles custom, édition matrice permissions.
API Keys org/workspace/admin/api-keysClés au nom de l'organisation.
Paramètres/workspace/admin/settings/generalNom, logo, locale par défaut, fuseau.
Entités facturation/workspace/admin/settings/billingBillingEntity : SIRET, IBAN, séquences de numérotation, en-tête PDF.
Audit log/workspace/admin/auditJournal org, filtres acteur/action/période, export CSV.

Modèle de données (points clés)

  • Membership est 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 session
  • twoFactor — MFA TOTP (Google Authenticator, Bitwarden…)
  • magicLink — lien email 15 min, cas client externe
  • admin — 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 globalDescription
SUPERADMIN_PROVIDERTout pouvoir : créer/supprimer orgs, impersonifier, console super-admin.
ADMIN_PROVIDERSupport/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)

PermissionADMINMANAGERCOMMERCIALACCOUNTANTLEASE_OFFICERTECHNICIANCLIENT_USERAUDITOR
identity.user:invite
crm.opportunity:*RR/WRR
sales.invoice:*RRR/WRRR
lease.contract:sign
lease.contract:unlock
asset:*RRRR/WR/WRR
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éthodeRoutePermission
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/usersidentity.user:read
POST/api/workspace/users/inviteidentity.user:invite
PATCH/api/workspace/users/[id]identity.user:update
POST/api/workspace/users/[id]/suspendidentity.user:update
GET / POST/api/workspace/teamsidentity.user:read / :invite
GET/api/workspace/rolesidentity.permission:read
POST / PATCH / DELETE/api/workspace/roles/[id]identity.role:manage
POST/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)

email
string required
Adresse email de l'invité.
roleCode
string required
Code du rôle à affecter (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)
/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 = true est 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 : activeOrganizationId lu 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) et ADMIN_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énementCanalDestinataire
Invitation reçueEmailInvité
Invitation acceptéeIn-app + EmailInviteur
Mot de passe changéEmailUser concerné
Nouvelle session appareil inconnuEmail + In-appUser concerné
5 échecs login → verrouillageEmailUser concerné
Compte suspenduEmailUser concerné
Impersonation effectuéeEmailUser impersonifié (J+1 max)
API key proche expiration (J-7)In-app + EmailUser