diff --git a/src/Module/Core/Infrastructure/Security/PermissionVoter.php b/src/Module/Core/Infrastructure/Security/PermissionVoter.php new file mode 100644 index 0000000..808aeae --- /dev/null +++ b/src/Module/Core/Infrastructure/Security/PermissionVoter.php @@ -0,0 +1,66 @@ + + */ +final class PermissionVoter extends Voter +{ + /** + * Regex de reconnaissance des codes de permission. + * + * Contraintes : + * - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_). + * - Au moins un point de separation (ecarte les attributs atomiques + * type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY). + * - Segments en snake_case minuscule coherents avec les permissions + * declarees par les *Module::permissions() et validees par app:sync-permissions. + */ + private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'; + + protected function supports(string $attribute, mixed $subject): bool + { + return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof User) { + // Token anonyme ou user d'un autre type : on refuse explicitement. + // Les voters core (AuthenticatedVoter) se chargent deja du cas + // "pas authentifie du tout". + return false; + } + + if ($user->isAdmin()) { + // Bypass total : decision architecturale #343 section 11. + // Cette regle est dupliquee cote front dans usePermissions() + // et les deux doivent bouger ensemble si elle evolue un jour. + return true; + } + + return in_array($attribute, $user->getEffectivePermissions(), true); + } +} diff --git a/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php b/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php new file mode 100644 index 0000000..ded5a66 --- /dev/null +++ b/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php @@ -0,0 +1,221 @@ +voter = new PermissionVoter(); + } + + // --------------------------------------------------------------- + // Abstention : attributs non-RBAC + // --------------------------------------------------------------- + + /** + * Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule, + * ne correspond pas au pattern snake_case minuscule avec point. + */ + public function testAbstainsOnRoleAdminAttribute(): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['ROLE_ADMIN']); + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); + } + + /** + * Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules, + * pas de point de separation conforme au pattern RBAC. + */ + public function testAbstainsOnIsAuthenticatedAttribute(): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']); + + $this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result); + } + + /** + * Le voter s'abstient sur des attributs malformes : sans point ou avec + * majuscules. + */ + #[DataProvider('malformedAttributeProvider')] + public function testAbstainsOnMalformedAttribute(string $attribute): void + { + $user = $this->buildUser(username: 'alice', isAdmin: false); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, [$attribute]); + + $this->assertSame( + VoterInterface::ACCESS_ABSTAIN, + $result, + sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute), + ); + } + + /** + * @return array + */ + public static function malformedAttributeProvider(): array + { + return [ + 'sans point' => ['nodot'], + 'majuscule milieu' => ['HAS.UPPERCASE'], + 'commence chiffre' => ['1core.users.view'], + 'chaine vide' => [''], + ]; + } + + // --------------------------------------------------------------- + // Refus : utilisateur non reconnu + // --------------------------------------------------------------- + + /** + * Refuse l'acces quand le token ne porte pas une instance de User metier + * (ex: InMemoryUser de Symfony). + */ + public function testDeniesWhenUserIsNotAUserEntity(): void + { + $inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']); + $token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + } + + // --------------------------------------------------------------- + // Bypass admin + // --------------------------------------------------------------- + + /** + * Accorde l'acces systematiquement a un administrateur, meme sans aucune + * permission explicite assignee. + */ + public function testGrantsForAdminBypass(): void + { + // Admin sans role ni permission directe : le bypass doit suffire. + $user = $this->buildUser(username: 'admin', isAdmin: true); + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + // --------------------------------------------------------------- + // Permissions effectives via role + // --------------------------------------------------------------- + + /** + * Accorde l'acces quand l'utilisateur possede la permission exacte via un role. + */ + public function testGrantsWhenUserHasExactPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + $role = new Role('viewer', 'Viewer'); + $role->addPermission($permission); + + $user = $this->buildUser(username: 'alice', isAdmin: false); + $user->addRbacRole($role); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + /** + * Refuse l'acces quand l'utilisateur possede une permission differente de + * celle demandee. + */ + public function testDeniesWhenUserLacksPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + $role = new Role('viewer', 'Viewer'); + $role->addPermission($permission); + + $user = $this->buildUser(username: 'alice', isAdmin: false); + $user->addRbacRole($role); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + // L'utilisateur a core.users.view mais pas core.roles.manage. + $result = $this->voter->vote($token, null, ['core.roles.manage']); + + $this->assertSame(VoterInterface::ACCESS_DENIED, $result); + } + + // --------------------------------------------------------------- + // Permissions directes (hors roles) + // --------------------------------------------------------------- + + /** + * Accorde l'acces via une permission directe (assignee sans passer par un role). + */ + public function testGrantsForDirectPermission(): void + { + $permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core'); + + $user = $this->buildUser(username: 'bob', isAdmin: false); + $user->addDirectPermission($permission); + + $token = new UsernamePasswordToken($user, 'main', $user->getRoles()); + + $result = $this->voter->vote($token, null, ['core.users.view']); + + $this->assertSame(VoterInterface::ACCESS_GRANTED, $result); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Construit un User metier minimal sans persistance. + */ + private function buildUser(string $username, bool $isAdmin): User + { + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin($isAdmin); + // Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface. + $user->setPassword('hashed_placeholder'); + + return $user; + } +}