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¶
- Couche 1 — Modèle Prisma
- Couche 2 — Backend Express
- Couche 3 — Frontend React
- Données initiales (Seed)
- 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 à soncommuneId.
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.