Files
Coltura/src/Module/Core/Domain/Entity/User.php
tristan edbe54cb8a fix(admin) : recherche insensible a la casse + label permissions dans filtres
SearchFilter partial etait case-sensitive en PostgreSQL : taper "ad" ne
trouvait pas "Administrateur". Passage en strategy `ipartial` (ILIKE) sur
tous les champs texte filtrables :
- Role.label / Role.code
- Site.name / Site.city / Site.postalCode
- User.username

Les filtres exacts sur relations (rbacRoles.code, sites.name,
permissions.code) restent en `exact` — alimentes par des <select> donc
casse maitrisee.

Test de non-regression ajoute : chercher "ad" (minuscule) trouve bien
"Administrateur" (A majuscule) sur /api/roles?label=ad.

UI select permissions du filtre /admin/roles : affiche `perm.label`
("Gerer les roles et permissions") au lieu de `perm.code`
("core.roles.manage"). Value reste sur `code` pour le backend. Tri
par label pour coherence alphabetique avec l'affichage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:28:01 +02:00

471 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
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\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/me',
provider: MeProvider::class,
normalizationContext: ['groups' => ['me:read']],
),
new Get(
security: "is_granted('core.users.view')",
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
security: "is_granted('core.users.view')",
normalizationContext: ['groups' => ['user:list']],
),
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
new Patch(
name: 'user_rbac_patch',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
],
denormalizationContext: ['groups' => ['user:write']],
)]
// Filtres /admin/users : recherche partielle insensible a la casse
// (ILIKE) sur username + filtre bool isAdmin + filtres exacts sur les
// relations (code de role ou nom de site).
// Les relations sont filtrees par jointure : `rbacRoles.code=admin` declenche
// un INNER JOIN user_role → role. `sites.name=Chatellerault` declenche
// INNER JOIN user_site → site.
#[ApiFilter(SearchFilter::class, properties: [
'username' => 'ipartial',
'rbacRoles.code' => 'exact',
'sites.name' => 'exact',
])]
#[ApiFilter(BooleanFilter::class, properties: ['isAdmin'])]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null;
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
// Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac.
// Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer
// la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName).
#[Groups(['user:rbac:write'])]
private bool $isAdmin = false;
/**
* Les roles RBAC metier rattaches a l'utilisateur.
*
* Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
* un refresh de token JWT ou une serialisation hors contexte EntityManager
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
*
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
// La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
// collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
// on reexpose la cle JSON sous `roles` via SerializedName pour rester
// conforme au contrat API documente dans le ticket #344.
#[SerializedName('roles')]
private Collection $rbacRoles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $rbacRoles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
* le chargement est defere jusqu'a l'acces explicite a la collection.
* MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
* precharger cette collection pour /api/me afin d'eviter N+1.
*
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*
* Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read'])]
private ?SiteInterface $currentSite = null;
#[ORM\Column]
private ?string $password = null;
#[Groups(['user:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->sites = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): static
{
$this->username = $username;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->username;
}
/**
* Retourne les roles Symfony Security, derives de $isAdmin.
*
* ROLE_USER est toujours present pour que Symfony accepte l'authentification.
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
*
* Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
* ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
* la Collection peut ne pas etre hydratee. On se contente d'un calcul
* base sur un scalaire.
*
* @see getRbacRoles() pour la collection RBAC metier (exposee en JSON sous la cle "roles").
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = ['ROLE_USER'];
if ($this->isAdmin) {
$roles[] = 'ROLE_ADMIN';
}
return $roles;
}
// Groupes de lecture + nom serialise explicite pour eviter que Symfony
// ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin".
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
#[SerializedName('isAdmin')]
public function isAdmin(): bool
{
return $this->isAdmin;
}
public function setIsAdmin(bool $isAdmin): static
{
$this->isAdmin = $isAdmin;
return $this;
}
/**
* Retourne la collection de roles RBAC rattaches a l'utilisateur.
*
* NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
*
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): static
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
return $this;
}
public function removeRbacRole(Role $role): static
{
$this->rbacRoles->removeElement($role);
return $this;
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): static
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
return $this;
}
public function removeDirectPermission(Permission $permission): static
{
$this->directPermissions->removeElement($permission);
return $this;
}
/**
* Retourne l'union dedupliquee des codes de permissions effectives.
*
* Agrege les permissions venant des roles RBAC et les permissions directes.
* Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
* apres l'evolution du MeProvider (aussi ticket #345).
*
* Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
* methode pour le piege de chargement au refresh JWT.
*
* @return list<string>
*/
#[Groups(['me: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;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
/**
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
*/
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->addUser($this);
}
return $this;
}
/**
* Retire un site de la collection + maintient la collection inverse en
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
* si le site retire en etait le courant — cet invariant est enforce
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(SiteInterface $site): static
{
if ($this->sites->removeElement($site)) {
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->removeUser($this);
}
return $this;
}
/**
* Garde applicative rapide : teste la presence d'un site dans la
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(SiteInterface $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?SiteInterface
{
return $this->currentSite;
}
/**
* Setter brut, sans garde. Usage interne pour les flux qui doivent
* pouvoir positionner un site arbitraire ou null (reset de coherence
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?SiteInterface $currentSite): static
{
$this->currentSite = $currentSite;
return $this;
}
/**
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
* dans la collection autorisee. Levee d'une exception domaine que le
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
* → SystemRoleDeletionException).
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(SiteInterface $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);
}
$this->currentSite = $site;
}
}