RBAC - Système complet de permissions (Backend + Frontend) (#7)
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: #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:
2026-04-17 12:34:38 +00:00
committed by Autin
parent b59d0f8a44
commit e8c2789435
65 changed files with 9985 additions and 386 deletions

View File

@@ -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;