Authentification & RBAC

Référence technique de la couche auth — Better Auth, sessions, rôles, matrice de permissions, vérification API/UI, audit log.

Authentification & RBAC

Pulse ERP utilise Better Auth 1.4+ comme unique couche d'authentification, avec le plugin Organization pour le multi-tenant natif. Cette page est la référence développeur : ne touchez pas à server/lib/auth.ts sans avoir lu cette documentation.

server/lib/auth.ts et server/lib/handler.ts sont des fondations sécurité. Toute modification doit être relue par un second développeur et couverte d'un test d'intégration.

Better Auth — pile et configuration

Email + mot de passe

Hash bcrypt à coût adapté. Vérification d'email obligatoire. Minimum 12 caractères (maj + min + chiffre + spécial). Pas de force-rotation périodique (NIST 800-63B).

Magic link

Lien à usage unique valide 15 minutes, envoyé par email. Cas d'usage : client externe sans mot de passe mémorisé, compte d'audit temporaire.

MFA TOTP

Compatible Google Authenticator, 1Password, Bitwarden, Authy. Obligatoire pour tous les rôles internes. Optionnel (mais recommandé) pour CLIENT_USER.

Sessions DB

Table Session, cookie HttpOnly Secure SameSite=Lax. Durée 30 jours glissants, expiration absolue 90 jours. Invalidation immédiate possible.

server/lib/auth.ts
export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  baseURL: process.env.AUTH_BASE_URL!,
  secret: process.env.AUTH_SECRET!,
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    minPasswordLength: 12,
    autoSignIn: false,
  },
  session: {
    expiresIn: 60 * 60 * 24 * 30,       // 30 jours
    updateAge: 60 * 60 * 24,             // refresh quotidien
    cookieCache: { enabled: true, maxAge: 5 * 60 },
  },
  plugins: [
    organization({ allowUserToCreateOrganization: false, organizationLimit: 50 }),
    twoFactor({ issuer: 'Pulse ERP', skipVerificationOnEnable: false }),
    magicLink({ expiresIn: 15 * 60, sendMagicLink: /* email helper */ }),
    admin({ adminRoles: ['SUPERADMIN_PROVIDER', 'ADMIN_PROVIDER'], impersonationSessionDuration: 3600 }),
  ],
})

Endpoints Better Auth exposés

Le catch-all server/api/auth/[...all].ts délègue tout à auth.handler().

EndpointUsage
POST /api/auth/sign-in/emailConnexion email + mot de passe
POST /api/auth/sign-outDéconnexion
POST /api/auth/magic-link/send · /verifyMagic link
POST /api/auth/two-factor/enable · /verifyActivation et vérification TOTP
POST /api/auth/forget-password · /reset-passwordRéinitialisation mot de passe
POST /api/auth/organization/set-activeChanger d'organisation active
POST /api/auth/admin/impersonate · /stop-impersonatingImpersonation support

Composable front

app/composables/useAuthClient.ts
export const useAuthClient = () => createAuthClient({
  baseURL: useRuntimeConfig().public.authBaseUrl,
  plugins: [organizationClient(), twoFactorClient(), magicLinkClient(), adminClient()],
})

Parcours utilisateur

Aucune auto-inscription publique — tout compte est créé par invitation ou par le super-admin Pulse.

  1. Invitation : l'admin saisit email + rôle → email d'invitation → l'invité choisit son mot de passe, active MFA, accepte les CGU → Membership créé.
  2. Première connexion : si Account.passwordResetRequired = true, redirection forcée vers /auth/set-password, puis écran MFA TOTP, puis acceptation CGU.
  3. Connexion standard : email + mot de passe → code TOTP si MFA actif → sélection d'organisation si l'utilisateur appartient à plusieurs → dashboard selon rôle.
Aucune session ne s'ouvre sans organisation active. Si activeOrganizationId est absent, l'API retourne 401.

Multi-tenant — organisation active

Better Auth Organization plugin injecte session.activeOrganizationId dans chaque cookie de session. Toutes les routes API lisent cette valeur depuis la session ; elle n'est jamais acceptée depuis le corps de la requête.

// ✅ Correct
const organizationId = ctx.organizationId // injecté par defineApiHandler

// ❌ Interdit
const organizationId = body.organizationId

Un même utilisateur peut appartenir à plusieurs organisations (ex. : salarié Pulse + client en AUDITOR). Le composant OrganizationSwitcher dans la topbar appelle POST /api/auth/organization/set-active.


Modèle de rôles

Deux niveaux : rôles globaux Pulse (transverses, hors org) et rôles métier par organisation.

Rôles globaux

RôleDroits
SUPERADMIN_PROVIDERTous les droits, création/suppression d'organisations, impersonation complète
ADMIN_PROVIDERSupport / DSI Pulse — consultation + impersonation lecture seule, pas de gestion d'org

Rôles métier (par Membership)

RôleProfil
ADMINAdmin de l'organisation cliente
MANAGEREncadrement / direction
COMMERCIALCommercial terrain
SALES_BACKADV / commercial sédentaire
ACCOUNTANTComptable / gestionnaire
LEASE_OFFICERChargé d'affaires LLD
FINANCE_OFFICERResponsable bailleurs
TECHNICIANTechnicien SAV terrain
SERVICE_MANAGERResponsable SAV
LOGISTICSLogistique
CLIENT_USERPortail client — lecture sur sa flotte
CLIENT_ADMINPortail client — peut inviter des membres
LENDER_USERPortail bailleur — lecture sur ses contrats cédés
PARTNER_USERPortail partenaire
SUPPLIER_USERPortail fournisseur
AUDITORLecture seule, watermark « AUDIT »

Les rôles métier sont modifiables par organisation via Role + RolePermission. Un rôle peut hériter des permissions d'un autre avec des ajustements.


Matrice de permissions (extrait)

Format des permissions : domain.entity:action. Légende : = oui · R = lecture · R/W = lecture+écriture · = aucun accès.

PermissionADMINMANAGERCOMMERCIALSALES_BACKACCOUNTANTLEASE_OFFICERFINANCE_OFFICERTECHNICIANLOGISTICSCLIENT_USERCLIENT_ADMINLENDER_USERAUDITOR
identity.user:invite
annuaire.company:*R/WR/WRRRRRR
asset:*RRRRR/WRR/WR/WRRRR
asset.maintenance:*RRRRRRR/WRRRRR
crm.opportunity:*RR/WR/WRR
sales.quote:*RR/WR/WRRRRR
sales.invoice:*RRRR/WRRRRRR
sales.payment:*RR/WRRR
lease.affair:*RRRR/WRRRR
lease.contract:sign
lease.contract:unlock
lease.cession:*RR
file:*RR/WR/WR/WR/WR/WR/WR/WRRRR
admin.*R
ai.workflow:run

Ces valeurs sont les défauts du seed. Chaque organisation peut les personnaliser via son admin.


Vérification des permissions — defineApiHandler

Toute route API passe par defineApiHandler (dans server/lib/handler.ts). Il vérifie la session, résout organizationId, contrôle la permission déclarée, exécute le handler, puis écrit l'audit log si demandé.

server/api/workspace/assets/index.post.ts
export default defineApiHandler(
  {
    permission: 'asset:create',
    audit: { action: 'asset.create', targetType: 'Asset' },
  },
  async (event, ctx) => {
    const input = await validateBody(event, AssetCreateDto)
    return createAsset({ organizationId: ctx.organizationId!, actor: ctx.session.user, input })
  },
)

ctx expose :

  • ctx.session — session Better Auth complète
  • ctx.organizationIdstring (jamais undefined si requireOrganization n'est pas désactivé)

Côté UI, la même permission est vérifiée dans les composables/pages via usePermission('asset:create') pour masquer les actions inaccessibles.


Garde-fous métier

lease.contract:unlock — cas particulier

Le déverrouillage d'un contrat signé est une action exceptionnelle.

lease.contract:unlock est réservé au rôle ADMIN de l'organisation par défaut. Aucun autre rôle métier ne peut l'obtenir sans modification explicite du super-admin.

Conditions obligatoires à chaque déverrouillage :

  • Raison textuelle (min 20 caractères).
  • Audit log dédié lease.contract.unlock avec unlockedAt, unlockedById, unlockReason.
  • Notification à la direction Pulse (super-admin alerté par email).
  • Re-verrouillage automatique après 24 heures.

Impersonation

Réservée à SUPERADMIN_PROVIDER (écriture) et ADMIN_PROVIDER (lecture seule via middleware impersonation-readonly.ts).

Garde-fous actifs :

  • Bandeau visuel rouge persistant : « Vous impersonifiez X (admin : Y). Arrêter ».
  • Session limitée à 1 heure.
  • Chaque action porte impersonatorUserId ET actorUserId distincts dans l'audit log.
  • Email de notification au compte impersonifié dans les 24 heures.

Audit log

Quand journaliser

ObligatoireRecommandéInterdit
Toute mutation sur entités businessAccès lecture aux données personnelles (RGPD grands comptes)Mots de passe, tokens, secrets dans payload
Transitions de workflow (opportunité → contrat → cession)Logs sur les requêtes de listing
Actions sensibles : impersonation, unlock contrat, suppression user, export
Actions admin (suspension org, modification paramètres)

Rétention

  • 10 ans (couverture comptable + commerciale).
  • Archive S3 mensuelle en Object Lock Compliance 10 ans.
  • Consultation dans l'app sur 12 mois glissants ; restauration archive sur demande au-delà.

Affichage

  • Page /admin/audit : filtres par acteur, action, période, cible.
  • Onglet « Historique » sur chaque fiche entité (composant AuditTrail réutilisable).

Tokens API (ApiKey)

Format : pk_live_ + 24 caractères aléatoires. Le hash bcrypt est stocké, le préfixe est visible en UI.

  • Scopes restrictifs : pas de token all-access.
  • Expiration max 90 jours par défaut.
  • lastUsedAt mis à jour à chaque appel.
  • Révocation immédiate.

Tests attendus

Pour tout endpoint API, les tests d'intégration doivent couvrir : utilisateur sans session → 401 ; rôle insuffisant → 403 ; ressource d'une autre organisation → 404 (pas 403, pour ne pas confirmer l'existence) ; impersonation lecture seule → 403 sur mutation.

Tests E2E Playwright obligatoires : login complet avec MFA, reset mot de passe, verrouillage après 5 échecs, switch organisation.