- Ajoute #[Groups(['me:read'])] sur getEffectivePermissions() dans User.php
- Fixe la serialisation de isAdmin : le prefixe "is" etait strip par Symfony,
expose desormais via le getter avec #[SerializedName('isAdmin')] + groups lecture,
la propriete conserve uniquement le groupe d'ecriture user:rbac:write
- Cree MeApiTest avec 4 tests fonctionnels (isAdmin admin, permissions vides user,
401 sans auth, effectivePermissions avec role portant une permission)
317 lines
9.7 KiB
PHP
317 lines
9.7 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;
|
|
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']],
|
|
)]
|
|
#[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;
|
|
|
|
#[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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|