['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']], )] #[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 */ #[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) ; // 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 */ #[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 */ #[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 */ 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 */ 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 */ 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 */ #[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 */ 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; } }