feat(core) : RBAC #345 - PermissionVoter symfony
This commit is contained in:
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voter RBAC qui evalue les codes de permission metier au format
|
||||||
|
* "module.resource.action" (ex: "core.users.view").
|
||||||
|
*
|
||||||
|
* - Ignore silencieusement les attributs non-RBAC (ROLE_*, IS_AUTHENTICATED_*, ...),
|
||||||
|
* qui restent traites par les voters core de Symfony. Strategy 'affirmative'
|
||||||
|
* par defaut : tant qu'un voter repond GRANTED, l'acces est accorde.
|
||||||
|
* - Bypass total si l'utilisateur porte le flag isAdmin (decision architecturale
|
||||||
|
* gravee au ticket #343 section 11 : is_admin est le seul levier technique
|
||||||
|
* de bypass, jamais remplace par un check de role).
|
||||||
|
* - Sinon, compare l'attribut aux permissions effectives de l'utilisateur
|
||||||
|
* (union dedupliquee triee venant des roles et des permissions directes).
|
||||||
|
*
|
||||||
|
* @extends Voter<string, mixed>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Core\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Infrastructure\Security\PermissionVoter;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du PermissionVoter RBAC.
|
||||||
|
*
|
||||||
|
* Le voter est teste via sa methode publique vote() qui retourne une des
|
||||||
|
* trois constantes VoterInterface : ACCESS_GRANTED, ACCESS_DENIED, ACCESS_ABSTAIN.
|
||||||
|
* - ACCESS_ABSTAIN : supports() a retourne false (attribut non-RBAC).
|
||||||
|
* - ACCESS_GRANTED / ACCESS_DENIED : voteOnAttribute() a ete invoque.
|
||||||
|
*
|
||||||
|
* Aucun acces base de donnees : toutes les entites sont construites en memoire.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class PermissionVoterTest extends TestCase
|
||||||
|
{
|
||||||
|
private PermissionVoter $voter;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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<string, array{string}>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user