Aller au contenu

Implémentation RBAC — Plateforme de Suivi des Enfants en Situation de Mendicité

Gestion des rôles et permissions sur 3 couches : Prisma · Express · React
Approche dynamique : rôles et permissions entièrement gérés en base de données, sans code en dur.


Table des matières

  1. Couche 1 — Modèle Prisma
  2. Couche 2 — Backend Express
  3. Couche 3 — Frontend React
  4. Données initiales (Seed)
  5. Résumé de l'architecture

1. Couche 1 — Modèle Prisma

1.1 Tables Rôles, Permissions et pivot

model Role {
  id           String           @id @default(cuid())
  code         String           @unique   // ex: "AGENT_TERRAIN"
  libelle      String                     // ex: "Agent de terrain"
  description  String?
  actif        Boolean          @default(true)

  utilisateurs Utilisateur[]
  permissions  RolePermission[]

  createdAt    DateTime         @default(now())
  updatedAt    DateTime         @updatedAt
}

model Permission {
  id          String           @id @default(cuid())
  code        String           @unique   // ex: "ENFANT_CREER"
  libelle     String                     // ex: "Créer une fiche enfant"
  description String?
  module      String                     // ex: "ENFANTS", "PILOTAGE", "ADMIN"

  roles       RolePermission[]

  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt
}

model RolePermission {
  roleId       String
  permissionId String

  role         Role       @relation(fields: [roleId], references: [id])
  permission   Permission @relation(fields: [permissionId], references: [id])

  @@id([roleId, permissionId])
}

1.2 Modèle Utilisateur

model Utilisateur {
  id          String   @id @default(cuid())
  email       String   @unique
  motDePasse  String
  actif       Boolean  @default(true)

  roleId      String
  role        Role     @relation(fields: [roleId], references: [id])

  // Commune d'affectation
  // null → accès toutes communes (MEAL National, Chargé de projet, Admin)
  // renseigné → accès restreint à cette commune (Agent terrain, MEAL Communal)
  communeId   String?
  commune     Commune? @relation(fields: [communeId], references: [id])

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Règle clé : l'isolation par commune est portée par la permission ACCES_TOUTES_COMMUNES. Si l'utilisateur possède cette permission, il accède à toutes les communes. Sinon, son accès est restreint à son communeId.


2. Couche 2 — Backend Express

2.1 Structure des fichiers

src/
├── middlewares/
│   ├── authenticate.ts      ← vérifie le JWT, charge utilisateur + permissions
│   ├── authorize.ts         ← vérifie qu'une permission est présente
│   └── scopeCommune.ts      ← filtre automatique par commune

2.2 Middleware d'authentification

On charge les permissions du rôle une seule fois, à chaque requête authentifiée.

// middlewares/authenticate.ts
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';

export const authenticate = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'Non authentifié' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET) as { userId: string };

    const utilisateur = await prisma.utilisateur.findUnique({
      where: { id: payload.userId },
      include: {
        commune: true,
        role: {
          include: {
            permissions: {
              include: { permission: true }
            }
          }
        }
      }
    });

    if (!utilisateur || !utilisateur.actif) {
      return res.status(401).json({ message: 'Compte inactif ou introuvable' });
    }

    // Extraction des codes de permissions pour un accès rapide
    req.user = {
      ...utilisateur,
      permissionCodes: utilisateur.role.permissions.map(rp => rp.permission.code)
    };
    // req.user.permissionCodes === ["ENFANT_LIRE", "ENFANT_CREER", ...]

    next();
  } catch {
    return res.status(401).json({ message: 'Token invalide' });
  }
};

2.3 Middleware d'autorisation par permission

// middlewares/authorize.ts
export const authorize = (...permissionsRequises: string[]) => {
  return (req, res, next) => {
    const aLaPermission = permissionsRequises.every(p =>
      req.user.permissionCodes.includes(p)
    );
    if (!aLaPermission) {
      return res.status(403).json({ message: 'Accès refusé' });
    }
    next();
  };
};

2.4 Middleware de scope commune

// middlewares/scopeCommune.ts
export const scopeCommune = (req, res, next) => {
  const aAccesGlobal = req.user.permissionCodes.includes('ACCES_TOUTES_COMMUNES');

  if (aAccesGlobal) {
    // Pas de filtre — accès à toutes les communes
    req.communeFilter = {};
  } else {
    // Filtre automatique sur la commune d'affectation
    req.communeFilter = { communeId: req.user.communeId };
  }

  next();
};

2.5 Utilisation dans les routes

// routes/enfants.ts
import { authenticate } from '../middlewares/authenticate';
import { authorize }    from '../middlewares/authorize';
import { scopeCommune } from '../middlewares/scopeCommune';

// Lecture — toute personne ayant la permission ENFANT_LIRE
router.get('/',
  authenticate,
  authorize('ENFANT_LIRE'),
  scopeCommune,
  async (req, res) => {
    const enfants = await prisma.enfant.findMany({
      where: { ...req.communeFilter }
    });
    res.json(enfants);
  }
);

// Création — permission ENFANT_CREER
router.post('/',
  authenticate,
  authorize('ENFANT_CREER'),
  scopeCommune,
  async (req, res) => {
    const enfant = await prisma.enfant.create({
      data: { ...req.body, communeId: req.user.communeId }
    });
    res.status(201).json(enfant);
  }
);

// Validation — permission ENFANT_VALIDER
router.patch('/:id/valider',
  authenticate,
  authorize('ENFANT_VALIDER'),
  async (req, res) => {
    // logique de validation...
  }
);

// Suppression — permission ENFANT_SUPPRIMER
router.delete('/:id',
  authenticate,
  authorize('ENFANT_SUPPRIMER'),
  async (req, res) => {
    // logique de suppression...
  }
);

3. Couche 3 — Frontend React

3.1 Store d'authentification

// store/authStore.ts  (Zustand)
import { create } from 'zustand';

interface User {
  id: string;
  role: { code: string; libelle: string };
  communeId: string | null;
  commune: { nom: string } | null;
  permissionCodes: string[];   // chargé depuis l'API à la connexion
}

interface AuthState {
  user: User | null;
  setUser: (user: User | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

3.2 Hook de permission

Plus de matrice codée en dur — on interroge directement les permissions de l'utilisateur connecté.

// hooks/usePermission.ts
import { useAuthStore } from '../store/authStore';

export const usePermission = (permission: string): boolean => {
  const { user } = useAuthStore();
  if (!user) return false;
  return user.permissionCodes.includes(permission);
};

// Variante : vérifier plusieurs permissions à la fois
export const usePermissions = (permissions: string[]): boolean => {
  const { user } = useAuthStore();
  if (!user) return false;
  return permissions.every(p => user.permissionCodes.includes(p));
};

3.3 Affichage conditionnel du side menu

// components/SideMenu.tsx
import { usePermission } from '../hooks/usePermission';

const SideMenu = () => {
  const peutValider      = usePermission('ENFANT_VALIDER');
  const peutExporter     = usePermission('RAPPORT_EXPORTER');
  const peutVoirPilotage = usePermission('INDICATEUR_LIRE');
  const peutGererUser    = usePermission('UTILISATEUR_GERER');
  const peutVoirAudit    = usePermission('AUDIT_LIRE');

  return (
    <nav>
      {/* Toujours visible pour tout utilisateur connecté */}
      <MenuItem icon="🏠" label="Tableau de bord"       to="/dashboard" />
      <MenuItem icon="👥" label="Gestion des enfants"   to="/enfants" />
      <MenuItem icon="🤝" label="Accompagnements"       to="/accompagnements" />
      <MenuItem icon="🔄" label="Insertion socio-éco"   to="/insertion" />
      <MenuItem icon="🏫" label="Acteurs de terrain"    to="/acteurs" />

      {/* Validation des saisies */}
      {peutValider && (
        <MenuItem icon="✅" label="Validation des saisies" to="/validation" />
      )}

      {/* Pilotage & indicateurs */}
      {peutVoirPilotage && (
        <>
          <MenuItem icon="📊" label="Pilotage & Indicateurs"     to="/pilotage" />
          <MenuItem icon="📢" label="Sensibilisation"            to="/sensibilisation" />
          <MenuItem icon="🏘️" label="Mobilisation communautaire" to="/mobilisation" />
        </>
      )}

      {/* Rapports & exports */}
      {peutExporter && (
        <MenuItem icon="📁" label="Rapports & Exports" to="/rapports" />
      )}

      {/* Administration */}
      {peutGererUser && (
        <MenuItem icon="⚙️" label="Administration" to="/admin" />
      )}

      {/* Sécurité & Audit */}
      {peutVoirAudit && (
        <MenuItem icon="🔒" label="Sécurité & Audit" to="/audit" />
      )}
    </nav>
  );
};

export default SideMenu;

3.4 Protection des routes React

// components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom';
import { usePermission } from '../hooks/usePermission';

interface Props {
  permission: string;
  children: React.ReactNode;
}

const ProtectedRoute = ({ permission, children }: Props) => {
  const hasPermission = usePermission(permission);
  if (!hasPermission) return <Navigate to="/non-autorise" replace />;
  return <>{children}</>;
};

export default ProtectedRoute;
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ProtectedRoute from './components/ProtectedRoute';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        {/* Pages accessibles à tous les utilisateurs connectés */}
        <Route path="/dashboard"       element={<DashboardPage />} />
        <Route path="/enfants"         element={<EnfantsPage />} />
        <Route path="/accompagnements" element={<AccompagnementsPage />} />
        <Route path="/insertion"       element={<InsertionPage />} />
        <Route path="/acteurs"         element={<ActeursPage />} />

        {/* Pages protégées par permission */}
        <Route path="/validation" element={
          <ProtectedRoute permission="ENFANT_VALIDER">
            <ValidationPage />
          </ProtectedRoute>
        } />

        <Route path="/pilotage" element={
          <ProtectedRoute permission="INDICATEUR_LIRE">
            <PilotagePage />
          </ProtectedRoute>
        } />

        <Route path="/rapports" element={
          <ProtectedRoute permission="RAPPORT_EXPORTER">
            <RapportsPage />
          </ProtectedRoute>
        } />

        <Route path="/admin" element={
          <ProtectedRoute permission="UTILISATEUR_GERER">
            <AdminPage />
          </ProtectedRoute>
        } />

        <Route path="/audit" element={
          <ProtectedRoute permission="AUDIT_LIRE">
            <AuditPage />
          </ProtectedRoute>
        } />

        <Route path="/non-autorise" element={<NonAutorisePage />} />
      </Routes>
    </BrowserRouter>
  );
}

4. Données initiales (Seed)

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

async function main() {

  // 1. Créer les permissions
  const permissions = await Promise.all([
    // Module ENFANTS
    { code: 'ENFANT_LIRE',           libelle: 'Consulter les fiches enfants',          module: 'ENFANTS' },
    { code: 'ENFANT_CREER',          libelle: 'Créer une fiche enfant',                module: 'ENFANTS' },
    { code: 'ENFANT_MODIFIER',       libelle: 'Modifier une fiche enfant',             module: 'ENFANTS' },
    { code: 'ENFANT_SUPPRIMER',      libelle: 'Supprimer une fiche enfant',            module: 'ENFANTS' },
    { code: 'ENFANT_VALIDER',        libelle: 'Valider une saisie enfant',             module: 'ENFANTS' },
    // Module PILOTAGE
    { code: 'INDICATEUR_LIRE',       libelle: 'Consulter les indicateurs',             module: 'PILOTAGE' },
    { code: 'INDICATEUR_SAISIR',     libelle: 'Saisir les mesures d\'indicateurs',     module: 'PILOTAGE' },
    { code: 'RAPPORT_EXPORTER',      libelle: 'Exporter les rapports',                 module: 'PILOTAGE' },
    // Module SYSTEM
    { code: 'ACCES_TOUTES_COMMUNES', libelle: 'Accès multi-communes',                  module: 'SYSTEM' },
    // Module ADMIN
    { code: 'UTILISATEUR_GERER',     libelle: 'Gérer les comptes utilisateurs',        module: 'ADMIN' },
    { code: 'AUDIT_LIRE',            libelle: 'Consulter les journaux d\'audit',       module: 'ADMIN' },
  ].map(p => prisma.permission.upsert({
    where: { code: p.code },
    update: {},
    create: p
  })));

  // 2. Définir les permissions par rôle
  const rolesData = [
    {
      code: 'AGENT_TERRAIN',
      libelle: 'Agent de terrain',
      description: 'Saisie des données sur le terrain dans sa commune',
      permissions: ['ENFANT_LIRE', 'ENFANT_CREER', 'ENFANT_MODIFIER']
    },
    {
      code: 'MEAL_COMMUNAL',
      libelle: 'Responsable MEAL Communal',
      description: 'Contrôle qualité des données de sa commune',
      permissions: [
        'ENFANT_LIRE', 'ENFANT_CREER', 'ENFANT_MODIFIER', 'ENFANT_VALIDER',
        'INDICATEUR_LIRE', 'INDICATEUR_SAISIR', 'RAPPORT_EXPORTER'
      ]
    },
    {
      code: 'MEAL_NATIONAL',
      libelle: 'Responsable MEAL National',
      description: 'Contrôle qualité sur toutes les communes',
      permissions: [
        'ENFANT_LIRE', 'ENFANT_VALIDER',
        'INDICATEUR_LIRE', 'INDICATEUR_SAISIR', 'RAPPORT_EXPORTER',
        'ACCES_TOUTES_COMMUNES'
      ]
    },
    {
      code: 'CHARGE_PROJET',
      libelle: 'Chargé de projet',
      description: 'Pilotage stratégique et reporting bailleur',
      permissions: [
        'ENFANT_LIRE', 'INDICATEUR_LIRE', 'RAPPORT_EXPORTER',
        'ACCES_TOUTES_COMMUNES'
      ]
    },
    {
      code: 'ADMIN',
      libelle: 'Administrateur système',
      description: 'Gestion technique de la plateforme',
      permissions: [
        'ENFANT_LIRE', 'ENFANT_SUPPRIMER',
        'INDICATEUR_LIRE', 'RAPPORT_EXPORTER',
        'ACCES_TOUTES_COMMUNES', 'UTILISATEUR_GERER', 'AUDIT_LIRE'
      ]
    },
  ];

  // 3. Créer les rôles et leurs liaisons de permissions
  for (const roleData of rolesData) {
    const role = await prisma.role.upsert({
      where: { code: roleData.code },
      update: {},
      create: {
        code: roleData.code,
        libelle: roleData.libelle,
        description: roleData.description,
      }
    });

    for (const permCode of roleData.permissions) {
      const permission = await prisma.permission.findUnique({ where: { code: permCode } });
      if (permission) {
        await prisma.rolePermission.upsert({
          where: { roleId_permissionId: { roleId: role.id, permissionId: permission.id } },
          update: {},
          create: { roleId: role.id, permissionId: permission.id }
        });
      }
    }
  }

  console.log('✅ Seed RBAC terminé');
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

5. Résumé de l'architecture

Base de données
  Role ──< RolePermission >── Permission
    ↓
authenticate.ts
  → charge role + toutes ses permissions
  → construit req.user.permissionCodes[]
    ↓
authorize('ENFANT_CREER')
  → vérifie dans permissionCodes[]  →  403 si absent
    ↓
scopeCommune
  → 'ACCES_TOUTES_COMMUNES' présent ? filtre global : filtre commune
    ↓
Prisma findMany({ where: { ...communeFilter } })

─────────────────────────────────────────────────────

Frontend
  Login → API retourne user + permissionCodes[]
    ↓
  authStore.permissionCodes[]
    ↓
  usePermission('RAPPORT_EXPORTER') → true / false
    ↓
  SideMenu     → affichage conditionnel des items
  ProtectedRoute → redirection si permission absente

Principe de défense en profondeur

Niveau Mécanisme Rôle
Frontend usePermission + ProtectedRoute UX — masque ce qui n'est pas accessible
Backend middleware authorize Sécurité — bloque les appels API non autorisés
Base de données scopeCommune + Prisma where Isolation — garantit l'étanchéité des données par commune

Avantages de l'approche dynamique

Aspect Enum / code en dur Table Permission
Ajouter un rôle Migration SQL INSERT en base
Modifier les permissions d'un rôle Redéploiement Interface admin
Désactiver un rôle Impossible sans code actif = false
Créer un rôle personnalisé Impossible ✅ depuis l'UI
Auditer qui a quelle permission Difficile Requête SQL simple

Important : le frontend ne doit jamais être le seul garde-fou. Un utilisateur mal intentionné peut appeler l'API directement. La sécurité réelle est assurée par les middlewares backend. Le frontend ne fait que refléter les permissions — il ne les définit pas.