Files
Coltura/src/Module/Core/Domain/Entity/Role.php

234 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
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\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
*
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
* peuvent etre supprimes.
*/
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['role:read']],
security: "is_granted('core.roles.view')",
),
new Get(
normalizationContext: ['groups' => ['role:read']],
security: "is_granted('core.roles.view')",
),
new Post(
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
security: "is_granted('core.roles.manage')",
processor: RoleProcessor::class,
),
new Patch(
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
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']],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')]
private string $code;
#[ORM\Column(length: 255)]
#[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank]
private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['role:read', 'role:write'])]
private ?string $description = null;
// Volontairement exclu du groupe `role:write` : un client ne doit jamais
// pouvoir positionner ce flag via l'API. Seules les fixtures et migrations
// creent les roles systeme.
#[ORM\Column(name: 'is_system', options: ['default' => false])]
#[Groups(['role:read'])]
private bool $isSystem = false;
/** @var Collection<int, Permission> */
// Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
// - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
// serialisation hors contexte EntityManager (voir ticket #343, section
// 11 risque #1) ou la collection serait inaccessible et provoquerait
// une erreur opaque.
// - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
// d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
// - Si la volumetrie augmente significativement : revoir vers une
// projection cachee (ticket a ouvrir a ce moment-la).
#[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, bool $isSystem = false, ?string $description = null)
{
$this->code = $code;
$this->label = $label;
$this->isSystem = $isSystem;
$this->description = $description;
$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 getDescription(): ?string
{
return $this->description;
}
// Le getter est annote directement car la convention Symfony PropertyInfo
// strip le prefixe `is` et exposerait le champ sous le nom `system`. On
// pose donc un SerializedName explicite pour garantir la sortie JSON-LD
// sous `isSystem`, nom attendu par les clients de l'API.
#[Groups(['role:read'])]
#[SerializedName('isSystem')]
public function isSystem(): bool
{
return $this->isSystem;
}
/** @return Collection<int, Permission> */
public function getPermissions(): Collection
{
return $this->permissions;
}
/**
* Setter expose uniquement a la denormalisation API Platform pour
* permettre au RoleProcessor de detecter une tentative de modification
* du code (garde "code immuable"). Le code reste en pratique fige apres
* creation : le processor refuse toute modification via 400.
*
* @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes.
* Hors contexte API Platform, cette methode modifie silencieusement
* le code sans aucun garde.
*/
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
/**
* Met a jour le libelle affichable du role. Le code reste immuable pour
* garantir la stabilite des references cote fixtures et migrations.
*/
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
/**
* Met a jour la description libre du role (champ documentaire).
*/
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
/**
* Ajoute une permission au role. Idempotent : ajouter deux fois la meme
* permission n'entraine pas de doublon dans la collection.
*/
public function addPermission(Permission $permission): static
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
return $this;
}
/**
* Retire une permission du role. Idempotent : retirer une permission
* absente est un no-op silencieux.
*/
public function removePermission(Permission $permission): static
{
$this->permissions->removeElement($permission);
return $this;
}
/**
* Garde domaine : refuse la suppression d'un role marque comme systeme.
* La traduction HTTP (403) est faite au niveau application / API Platform.
*/
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw SystemRoleDeletionException::forRole($this);
}
}
}