feat(core) : add rbac role and permission entities with user relations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user