Resout les 5 findings de la review automatique + couverture ManyToMany annoncee dans CLAUDE.md : - AuditListener : resolution de la classe via ClassMetadata plutot que `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies sont des `Proxies\__CG__\...`). Test de regression via getReference(). - AuditListener : capture des modifications de collections to-many (OneToMany / ManyToMany) via getScheduledCollectionUpdates / getScheduledCollectionDeletions. Les diffs sont mergees dans le changeset existant ou creent une entree "update" dediee. - AuditLogResource + Provider : filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour alimenter le MalioSelectCheckbox cote front. - audit-log.vue : refonte complete. Passage a `MalioDataTable`, composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton), suppression complete de la persistance URL (`readQuery` / `syncQuery` / `route.query`). `datetime-local` conserve avec TODO pointant l'exception CLAUDE.md. - AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne avec un `itemsPerPage=10` passe au backend. Token anti-race pour ignorer les reponses tardives quand l'entite affichee change. - AuditLogDetail : affichage des diffs de collections to-many (+ / -) dans le tableau field/old/new existant. - logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un user suivant (meme onglet) voie l'etat audit de l'ancien. - Permission / Role / Site : marquage `#[Auditable]`. - Version bump 0.1.32 → 0.1.34. Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
7.8 KiB
PHP
236 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 App\Shared\Domain\Attribute\Auditable;
|
|
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`')]
|
|
#[Auditable]
|
|
#[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);
|
|
}
|
|
}
|
|
}
|