permissions) * et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique * ailleurs (ni SQL en dur, ni autre fixture). * * Consomme par : * - la commande applicative `app:seed-rbac` (presente dans le build prod, donc * rejouable en recette/prod, contrairement aux fixtures `require-dev`) ; * - la fixture Core dev/test (DRY : meme seeder). * * Toutes les operations sont idempotentes et non destructives : * - ensureRoles() : cree un role par lookup de code (skip si present) ; * - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission, * sans re-attacher un lien existant ; STOP explicite si un code manque ; * - ensureDemoUsers() : cree un user par role (lookup par username, skip si * present), rattache au role + a >= 1 site. */ final class RbacSeeder { /** * Codes des roles metier (snake_case, regex Role respectee). `commerciale` * reference la constante Shared deja consommee par le ClientProcessor * (RG-1.04) pour eviter tout drift : un seul litteral pour ce code. */ public const string ROLE_BUREAU = 'bureau'; public const string ROLE_COMPTA = 'compta'; public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE; public const string ROLE_USINE = 'usine'; /** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */ private const string DEFAULT_SITE_NAME = 'Chatellerault'; /** * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il * bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a * aucun role metier — admin seul). * * @var array}> */ private const array MATRIX = [ self::ROLE_BUREAU => [ 'label' => 'Bureau', 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', ], ], self::ROLE_COMPTA => [ 'label' => 'Comptabilité', 'permissions' => [ 'commercial.clients.view', 'commercial.clients.accounting.view', 'commercial.clients.accounting.manage', ], ], self::ROLE_COMMERCIALE => [ 'label' => 'Commerciale', 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', ], ], self::ROLE_USINE => [ 'label' => 'Usine', 'permissions' => [], ], ]; public function __construct( private readonly RoleRepositoryInterface $roleRepository, private readonly PermissionRepositoryInterface $permissionRepository, private readonly UserRepositoryInterface $userRepository, private readonly SiteProviderInterface $siteProvider, private readonly UserPasswordHasherInterface $passwordHasher, ) {} /** * Cree chaque role metier absent (lookup par code). Idempotent. * * @return list codes des roles effectivement crees (vide au rejeu) */ public function ensureRoles(): array { $created = []; foreach (self::MATRIX as $code => $definition) { if (null !== $this->roleRepository->findByCode($code)) { continue; } // isSystem=false : ce sont des roles metier, supprimables par un // admin (contrairement aux roles systeme admin/user). $this->roleRepository->save(new Role($code, $definition['label'], isSystem: false)); $created[] = $code; } return $created; } /** * Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup * de la permission par code ; un code absent leve une RbacSeedException * (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un * lien deja present n'est pas recree. * * @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu) * * @throws RbacSeedException si un role ou une permission de la matrice manque */ public function attachMatrix(): int { $added = 0; foreach (self::MATRIX as $code => $definition) { $role = $this->roleRepository->findByCode($code); if (null === $role) { throw RbacSeedException::missingRole($code); } $touched = false; foreach ($definition['permissions'] as $permissionCode) { $permission = $this->permissionRepository->findByCode($permissionCode); if (null === $permission) { throw RbacSeedException::missingPermission($permissionCode); } if (!$role->getPermissions()->contains($permission)) { $role->addPermission($permission); $touched = true; ++$added; } } // Un seul flush par role, et seulement si un lien a change. if ($touched) { $this->roleRepository->save($role); } } return $added; } /** * Cree un compte demo par role metier (username = code du role), non-admin, * mot de passe hashe, rattache a son role et a >= 1 site. Lookup par * username : idempotent (un compte existant est laisse intact, mot de passe * inchange). * * @return list usernames effectivement crees (vide au rejeu) * * @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue) */ public function ensureDemoUsers(string $password): array { // Rattachement a un site par defaut s'il existe (les flux login / me en // ont besoin ; le repertoire clients n'est pas site-scope mais on reste // coherent avec les fixtures admin/alice/bob). $defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME); $created = []; foreach (array_keys(self::MATRIX) as $code) { $username = $code; if (null !== $this->userRepository->findByUsername($username)) { continue; } $role = $this->roleRepository->findByCode($code); if (null === $role) { throw RbacSeedException::missingRole($code); } $user = new User(); $user->setUsername($username); $user->setIsAdmin(false); $user->setPassword($this->passwordHasher->hashPassword($user, $password)); $user->addRbacRole($role); if (null !== $defaultSite) { $user->addSite($defaultSite); $user->setCurrentSite($defaultSite); } $this->userRepository->save($user); $created[] = $username; } return $created; } /** * Liste des codes des roles metier definis (pour reporting / tests). * * @return list */ public static function roleCodes(): array { return array_keys(self::MATRIX); } }