Authentification & RBAC
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.
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().
| Endpoint | Usage |
|---|---|
POST /api/auth/sign-in/email | Connexion email + mot de passe |
POST /api/auth/sign-out | Déconnexion |
POST /api/auth/magic-link/send · /verify | Magic link |
POST /api/auth/two-factor/enable · /verify | Activation et vérification TOTP |
POST /api/auth/forget-password · /reset-password | Réinitialisation mot de passe |
POST /api/auth/organization/set-active | Changer d'organisation active |
POST /api/auth/admin/impersonate · /stop-impersonating | Impersonation support |
Composable front
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.
- Invitation : l'admin saisit email + rôle → email d'invitation → l'invité choisit son mot de passe, active MFA, accepte les CGU →
Membershipcréé. - Première connexion : si
Account.passwordResetRequired = true, redirection forcée vers/auth/set-password, puis écran MFA TOTP, puis acceptation CGU. - Connexion standard : email + mot de passe → code TOTP si MFA actif → sélection d'organisation si l'utilisateur appartient à plusieurs → dashboard selon rôle.
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ôle | Droits |
|---|---|
SUPERADMIN_PROVIDER | Tous les droits, création/suppression d'organisations, impersonation complète |
ADMIN_PROVIDER | Support / DSI Pulse — consultation + impersonation lecture seule, pas de gestion d'org |
Rôles métier (par Membership)
| Rôle | Profil |
|---|---|
ADMIN | Admin de l'organisation cliente |
MANAGER | Encadrement / direction |
COMMERCIAL | Commercial terrain |
SALES_BACK | ADV / commercial sédentaire |
ACCOUNTANT | Comptable / gestionnaire |
LEASE_OFFICER | Chargé d'affaires LLD |
FINANCE_OFFICER | Responsable bailleurs |
TECHNICIAN | Technicien SAV terrain |
SERVICE_MANAGER | Responsable SAV |
LOGISTICS | Logistique |
CLIENT_USER | Portail client — lecture sur sa flotte |
CLIENT_ADMIN | Portail client — peut inviter des membres |
LENDER_USER | Portail bailleur — lecture sur ses contrats cédés |
PARTNER_USER | Portail partenaire |
SUPPLIER_USER | Portail fournisseur |
AUDITOR | Lecture 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.
| Permission | ADMIN | MANAGER | COMMERCIAL | SALES_BACK | ACCOUNTANT | LEASE_OFFICER | FINANCE_OFFICER | TECHNICIAN | LOGISTICS | CLIENT_USER | CLIENT_ADMIN | LENDER_USER | AUDITOR |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
identity.user:invite | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
annuaire.company:* | ✅ | ✅ | R/W | R/W | R | R | R | R | R | ❌ | ❌ | ❌ | R |
asset:* | ✅ | R | R | R | R | R/W | R | R/W | R/W | R | R | R | R |
asset.maintenance:* | ✅ | R | R | R | R | R | R | R/W | R | R | R | R | R |
crm.opportunity:* | ✅ | R | R/W | R/W | R | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | R |
sales.quote:* | ✅ | R | R/W | R/W | R | R | ❌ | ❌ | ❌ | R | R | ❌ | R |
sales.invoice:* | ✅ | R | R | R | R/W | R | R | ❌ | ❌ | R | R | R | R |
sales.payment:* | ✅ | R | ❌ | ❌ | R/W | ❌ | ❌ | ❌ | ❌ | R | R | ❌ | R |
lease.affair:* | ✅ | R | R | ❌ | R | R/W | R | ❌ | ❌ | R | R | ❌ | R |
lease.contract:sign | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
lease.contract:unlock | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
lease.cession:* | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | R | R |
file:* | ✅ | R | R/W | R/W | R/W | R/W | R/W | R/W | R/W | R | R | R | R |
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é.
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ètectx.organizationId—string(jamaisundefinedsirequireOrganizationn'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.unlockavecunlockedAt,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
impersonatorUserIdETactorUserIddistincts dans l'audit log. - Email de notification au compte impersonifié dans les 24 heures.
Audit log
Quand journaliser
| Obligatoire | Recommandé | Interdit |
|---|---|---|
| Toute mutation sur entités business | Accè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
AuditTrailré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.
lastUsedAtmis à jour à chaque appel.- Révocation immédiate.
Tests attendus
Tests E2E Playwright obligatoires : login complet avec MFA, reset mot de passe, verrouillage après 5 échecs, switch organisation.