Le modèle de sécurité à trois niveaux
Feature flag, permission ACL, voter ownership : une route mutante traverse trois portes avant de toucher la base.
Une plateforme SaaS doit composer avec trois questions distinctes : est-ce que le module est activé pour ce tenant ?, est-ce que ce rôle peut faire cette action ?, est-ce que cet utilisateur possède cette ressource ?. Les fusionner produit des bugs subtils ; les séparer produit un modèle clair.
1. Feature flag — module-level
L'attribut #[RequiresFeature('cms.manage')] en tête de classe contrôleur fait redirige vers /access-denied si l'organisation n'a pas activé le module. Un seul kill-switch couvre toutes les routes du contrôleur, plus la barre latérale via is_feature_enabled('cms.manage').
2. Permission ACL — par rôle et par utilisateur
#[IsGranted(PermissionCodes::CMS_PAGE_UPDATE)] sur chaque méthode mutante, y compris les routes bulk. Les permissions sont distribuées par défaut selon le rôle (ROLE_USER, ROLE_ORGANIZATION_ADMIN) et peuvent être surchargées au niveau utilisateur.
3. Voter — ownership + scope
Avant chaque mutation entité-par-entité : denyAccessUnlessGranted(PageVoter::EDIT, $page). Le voter vérifie que $page->getOrganization() === $user->getOrganization(), que la ressource n'est pas soft-deleted, et que le statut autorise l'opération.
Pourquoi les trois ensemble ?
Une seule défaillance ne suffit pas à compromettre la sécurité. Un attaquant doit franchir le module (feature), le rôle (permission) et la ressource (voter). C'est ce que l'on appelle de la défense en profondeur, et c'est ce qui distingue une fondation prête pour la production d'un prototype.