['me:read']], ), new Get( normalizationContext: ['groups' => ['user:list']], ), new GetCollection( normalizationContext: ['groups' => ['user:list']], ), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Delete(security: "is_granted('ROLE_ADMIN')"), ], 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'])] 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])] #[Groups(['me:read', 'user:list'])] 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'])] private Collection $roles; /** * Les permissions directes accordees hors des roles. * * Meme justification EAGER que pour $roles : 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'])] private Collection $directPermissions; #[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->roles = new ArrayCollection(); $this->directPermissions = 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->roles (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. * * @return list */ public function getRoles(): array { $roles = ['ROLE_USER']; if ($this->isAdmin) { $roles[] = 'ROLE_ADMIN'; } return $roles; } 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->roles; } public function addRbacRole(Role $role): static { if (!$this->roles->contains($role)) { $this->roles->add($role); } return $this; } public function removeRbacRole(Role $role): static { $this->roles->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 */ public function getEffectivePermissions(): array { $codes = []; foreach ($this->roles 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; } }