RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: MALIO-DEV/Coltura#7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #7.
This commit is contained in:
153
src/Module/Core/Domain/Entity/Permission.php
Normal file
153
src/Module/Core/Domain/Entity/Permission.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?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\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||
#[ORM\Table(name: 'permission')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||
class Permission
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['permission:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $module;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['permission:read'])]
|
||||
private bool $orphan = false;
|
||||
|
||||
/**
|
||||
* Invariants : une permission doit avoir un code non vide respectant la
|
||||
* convention "module.resource[.sub].action" (donc contenir au moins un
|
||||
* point), un libelle non vide et un module proprietaire non vide. Ces
|
||||
* garde-fous evitent la persistence de lignes incoherentes si un appelant
|
||||
* (fixture, commande de synchro, import) oublie un champ ou passe une
|
||||
* chaine vide.
|
||||
*/
|
||||
public function __construct(string $code, string $label, string $module)
|
||||
{
|
||||
if ('' === $code) {
|
||||
throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.');
|
||||
}
|
||||
if (!str_contains($code, '.')) {
|
||||
throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code));
|
||||
}
|
||||
if ('' === $label) {
|
||||
throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.');
|
||||
}
|
||||
if ('' === $module) {
|
||||
throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre vide.');
|
||||
}
|
||||
|
||||
$this->code = $code;
|
||||
$this->label = $label;
|
||||
$this->module = $module;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getModule(): string
|
||||
{
|
||||
return $this->module;
|
||||
}
|
||||
|
||||
public function isOrphan(): bool
|
||||
{
|
||||
return $this->orphan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque la permission comme orpheline : son code n'est plus declare par
|
||||
* aucun module. Elle reste en base pour preserver les assignations et
|
||||
* permettre une reactivation ulterieure, mais doit etre ignoree par les
|
||||
* verifications d'autorisation.
|
||||
*/
|
||||
public function markOrphan(): static
|
||||
{
|
||||
$this->orphan = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive une permission precedemment orpheline : son code reapparait
|
||||
* dans le code source d'un module. Equivaut a updateMetadata() suivi d'un
|
||||
* clearing du flag orphan ; on delegue a updateMetadata() pour ne pas
|
||||
* dupliquer la logique d'affectation des metadonnees.
|
||||
*/
|
||||
public function revive(string $label, string $module): static
|
||||
{
|
||||
$this->updateMetadata($label, $module);
|
||||
$this->orphan = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour les metadonnees d'une permission active sans toucher a son
|
||||
* statut d'orphelin. Utilise par la commande de synchronisation lorsque
|
||||
* seul le libelle ou le module proprietaire a change cote code.
|
||||
*/
|
||||
public function updateMetadata(string $label, string $module): static
|
||||
{
|
||||
$this->label = $label;
|
||||
$this->module = $module;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
233
src/Module/Core/Domain/Entity/Role.php
Normal file
233
src/Module/Core/Domain/Entity/Role.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
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\Domain\Exception\SystemRoleDeletionException;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
|
||||
*
|
||||
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
|
||||
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
|
||||
* peuvent etre supprimes.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||
#[ORM\Table(name: '`role`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
||||
class Role
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['role:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
#[Assert\NotBlank]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
// Volontairement exclu du groupe `role:write` : un client ne doit jamais
|
||||
// pouvoir positionner ce flag via l'API. Seules les fixtures et migrations
|
||||
// creent les roles systeme.
|
||||
#[ORM\Column(name: 'is_system', options: ['default' => false])]
|
||||
#[Groups(['role:read'])]
|
||||
private bool $isSystem = false;
|
||||
|
||||
/** @var Collection<int, Permission> */
|
||||
// Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
|
||||
// - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
|
||||
// serialisation hors contexte EntityManager (voir ticket #343, section
|
||||
// 11 risque #1) ou la collection serait inaccessible et provoquerait
|
||||
// une erreur opaque.
|
||||
// - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
|
||||
// d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
|
||||
// - Si la volumetrie augmente significativement : revoir vers une
|
||||
// projection cachee (ticket a ouvrir a ce moment-la).
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'role_permission')]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
private Collection $permissions;
|
||||
|
||||
public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)
|
||||
{
|
||||
$this->code = $code;
|
||||
$this->label = $label;
|
||||
$this->isSystem = $isSystem;
|
||||
$this->description = $description;
|
||||
$this->permissions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
// Le getter est annote directement car la convention Symfony PropertyInfo
|
||||
// strip le prefixe `is` et exposerait le champ sous le nom `system`. On
|
||||
// pose donc un SerializedName explicite pour garantir la sortie JSON-LD
|
||||
// sous `isSystem`, nom attendu par les clients de l'API.
|
||||
#[Groups(['role:read'])]
|
||||
#[SerializedName('isSystem')]
|
||||
public function isSystem(): bool
|
||||
{
|
||||
return $this->isSystem;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Permission> */
|
||||
public function getPermissions(): Collection
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter expose uniquement a la denormalisation API Platform pour
|
||||
* permettre au RoleProcessor de detecter une tentative de modification
|
||||
* du code (garde "code immuable"). Le code reste en pratique fige apres
|
||||
* creation : le processor refuse toute modification via 400.
|
||||
*
|
||||
* @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes.
|
||||
* Hors contexte API Platform, cette methode modifie silencieusement
|
||||
* le code sans aucun garde.
|
||||
*/
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour le libelle affichable du role. Le code reste immuable pour
|
||||
* garantir la stabilite des references cote fixtures et migrations.
|
||||
*/
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour la description libre du role (champ documentaire).
|
||||
*/
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une permission au role. Idempotent : ajouter deux fois la meme
|
||||
* permission n'entraine pas de doublon dans la collection.
|
||||
*/
|
||||
public function addPermission(Permission $permission): static
|
||||
{
|
||||
if (!$this->permissions->contains($permission)) {
|
||||
$this->permissions->add($permission);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une permission du role. Idempotent : retirer une permission
|
||||
* absente est un no-op silencieux.
|
||||
*/
|
||||
public function removePermission(Permission $permission): static
|
||||
{
|
||||
$this->permissions->removeElement($permission);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde domaine : refuse la suppression d'un role marque comme systeme.
|
||||
* La traduction HTTP (403) est faite au niveau application / API Platform.
|
||||
*/
|
||||
public function ensureDeletable(): void
|
||||
{
|
||||
if ($this->isSystem) {
|
||||
throw SystemRoleDeletionException::forRole($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,18 @@ 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: [
|
||||
@@ -27,14 +32,24 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
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('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
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']],
|
||||
)]
|
||||
@@ -45,17 +60,52 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read', 'user:list'])]
|
||||
#[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;
|
||||
|
||||
/** @var list<string> */
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private array $roles = [];
|
||||
#[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;
|
||||
@@ -68,7 +118,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->rbacRoles = new ArrayCollection();
|
||||
$this->directPermissions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -93,23 +145,134 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* 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 = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
$roles = ['ROLE_USER'];
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
if ($this->isAdmin) {
|
||||
$roles[] = 'ROLE_ADMIN';
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/** @param list<string> $roles */
|
||||
public function setRoles(array $roles): static
|
||||
// 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
|
||||
{
|
||||
$this->roles = $roles;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user