feat(core) : add rbac role and permission entities with user relations

This commit is contained in:
Matthieu
2026-06-19 16:56:07 +02:00
parent fdc72573ea
commit ffed224979
15 changed files with 738 additions and 1 deletions
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
)]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read', 'role:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true, options: ['comment' => 'Permission code (module.resource[.sub].action)'])]
#[Groups(['permission:read', 'role:read'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable permission label'])]
#[Groups(['permission:read', 'role:read'])]
private string $label;
#[ORM\Column(length: 100, options: ['comment' => 'Owning module id (e.g. core)'])]
#[Groups(['permission:read', 'role:read'])]
private string $module;
#[ORM\Column(options: ['comment' => 'True when the permission is no longer declared by any active module'])]
#[Groups(['permission:read'])]
private bool $orphan = false;
public function __construct(string $code, string $label, string $module)
{
$code = trim($code);
$label = trim($label);
$module = trim($module);
if ('' === $code || !str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libellé de permission ne peut pas être vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module de permission ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
public function markOrphan(): void
{
$this->orphan = true;
}
public function revive(string $label, string $module): void
{
$this->orphan = false;
$this->updateMetadata($label, $module);
}
public function updateMetadata(string $label, string $module): void
{
$this->label = $label;
$this->module = $module;
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')"),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Delete(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true, options: ['comment' => 'Immutable role code (snake_case)'])]
#[Groups(['role:read', 'role:write'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable role label'])]
#[Groups(['role:read', 'role:write'])]
private string $label;
#[ORM\Column(type: 'text', nullable: true, options: ['comment' => 'Optional role description'])]
#[Groups(['role:read', 'role:write'])]
private ?string $description;
#[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])]
#[Groups(['role:read'])]
private bool $isSystem;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false)
{
if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) {
throw new InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code));
}
if ('' === trim($label)) {
throw new InvalidArgumentException('Le libellé de rôle ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->description = $description;
$this->isSystem = $isSystem;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->label = $label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
public function isSystem(): bool
{
return $this->isSystem;
}
/**
* @return Collection<int, Permission>
*/
public function getPermissions(): Collection
{
return $this->permissions;
}
public function addPermission(Permission $permission): void
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
}
public function removePermission(Permission $permission): void
{
$this->permissions->removeElement($permission);
}
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw new SystemRoleDeletionException($this->code);
}
}
}
+84 -1
View File
@@ -17,6 +17,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -135,9 +137,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
/**
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['user:rbac:read'])]
private Collection $rbacRoles;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['user:rbac:read'])]
private Collection $directPermissions;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -373,4 +393,67 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
return $this;
}
/**
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): void
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
}
public function removeRbacRole(Role $role): void
{
$this->rbacRoles->removeElement($role);
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): void
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
}
public function removeDirectPermission(Permission $permission): void
{
$this->directPermissions->removeElement($permission);
}
/**
* Permissions effectives = union (rôles RBAC → permissions) (permissions directes), triée, dédupliquée.
*
* @return list<string>
*/
#[Groups(['me:read', 'user:rbac:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use DomainException;
final class SystemRoleDeletionException extends DomainException
{
public function __construct(string $code)
{
parent::__construct(sprintf('Le rôle système "%s" ne peut pas être supprimé.', $code));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/** @return list<Permission> */
public function findAll(): array;
/** @return list<string> */
public function findAllCodes(): array;
public function save(Permission $permission): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/** @return list<Role> */
public function findAll(): array;
public function save(Role $role): void;
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<Role, null|Role>
*/
final readonly class RoleProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role
{
assert($data instanceof Role);
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->em->remove($data);
$this->em->flush();
return null;
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
final class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Permission> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
/** @return list<string> */
public function findAllCodes(): array
{
/** @var list<array{code: string}> $rows */
$rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult();
return array_map(static fn (array $r): string => $r['code'], $rows);
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
final class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Role> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
}
}
@@ -26,4 +26,7 @@ interface UserInterface
public function getAvatarUrl(): ?string;
public function getIsEmployee(): bool;
/** @return list<string> */
public function getEffectivePermissions(): array;
}