d3d00425f7
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 : l'archivage passe par PATCH isArchived). ClientProvider : - liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire ?pagination=false - exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true reintegre les archives (RG-1.25) - tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/ email) et ?categoryType=<code> - detail : 404 sur soft-delete, embarque contacts/adresses/ribs ClientProcessor : - normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21) - 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de restauration (RG-1.23) - gating par onglet : champ comptable -> accounting.manage, isArchived -> archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif (RG-1.22) + pose/retrait archivedAt - regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs + controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale) Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le groupe client:read:accounting selon commercial.clients.accounting.view. Resolution des references categorie : CategoryReferenceDenormalizer resout les IRI vers Category quand la propriete est type-hintee par le contrat CategoryInterface (denormalisation impossible sur une interface sinon). Contrats Shared : - CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la verification de type sans import inter-modules - BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE pour detecter le role metier ; le code de role sera seede par ERP-74 et reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte ce role. Coordination stack : - chaines de permission commercial.clients.* referencees ici, declarees en ERP-59 (tests RBAC complets en ERP-60) - config globale de pagination (itemsPerPage client, max 50) portee par ERP-72 - referentiels comptables (PaymentType/Bank/...) exposes en ERP-56 Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte (339 tests).
491 lines
17 KiB
PHP
491 lines
17 KiB
PHP
<?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\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 n'importe plus rien depuis le module Sites.
|
|
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
|
|
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
|
|
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
|
|
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
|
|
use App\Shared\Domain\Attribute\Auditable;
|
|
use App\Shared\Domain\Attribute\AuditIgnore;
|
|
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
|
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),
|
|
// Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
|
|
// API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
|
|
// Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
|
|
// pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
|
|
new Get(
|
|
name: 'user_rbac_get',
|
|
uriTemplate: '/users/{id}/rbac',
|
|
security: "is_granted('core.users.manage')",
|
|
normalizationContext: ['groups' => ['user:rbac:read']],
|
|
),
|
|
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']],
|
|
)]
|
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
|
#[ORM\Table(name: '`user`')]
|
|
#[Auditable]
|
|
class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface
|
|
{
|
|
#[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, SiteInterface>
|
|
*/
|
|
#[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
|
|
#[ORM\JoinTable(name: 'user_site')]
|
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
|
#[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: SiteInterface::class, fetch: 'LAZY')]
|
|
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
|
#[Groups(['me:read'])]
|
|
private ?SiteInterface $currentSite = null;
|
|
|
|
#[ORM\Column]
|
|
#[AuditIgnore]
|
|
private ?string $password = null;
|
|
|
|
#[Groups(['user:write'])]
|
|
#[AuditIgnore]
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
|
|
* rattaches porte le code donne. Permet aux modules tiers de detecter un
|
|
* role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
|
|
* cette classe. Comparaison stricte sur Role::code.
|
|
*/
|
|
public function hasBusinessRole(string $roleCode): bool
|
|
{
|
|
foreach ($this->rbacRoles as $role) {
|
|
if ($role->getCode() === $roleCode) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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, SiteInterface>
|
|
*/
|
|
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.
|
|
* La classe concrete injectee au runtime est resolue par Doctrine via
|
|
* `resolve_target_entities` (cf. note architecture en tete de fichier).
|
|
*/
|
|
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;
|
|
}
|
|
}
|