## 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>
20 KiB
Ticket #344 - 2/5 - API CRUD Roles & Permissions (Backend)
1. Objectif
Exposer via API Platform le socle RBAC livre par le ticket #343 (entites Role, Permission, relations User->roles/directPermissions, flag isAdmin). Ce ticket livre la surface HTTP minimale permettant :
- de lister et consulter les permissions synchronisees par
app:sync-permissions, - de gerer le cycle de vie des roles (CRUD) tout en protegeant les roles systeme,
- d'attribuer
isAdmin, les roles RBAC et les permissions directes a un utilisateur sans polluer le groupeuser:write(commit0fc4e16).
Le ticket n'introduit aucune logique d'autorisation metier : toute la verification is_granted('module.resource.action') est traitee par le voter du ticket #345. A ce stade, les operations sont gardees par un simple is_granted('ROLE_ADMIN'), remplace au #345.
2. Perimetre
IN
- Exposer l'entite
Permissionen API Platform en lecture seule (GetCollection,Get), groupepermission:read, filtresmoduleetorphan. - Exposer l'entite
Roleen API Platform avec CRUD complet (GetCollection,Get,Post,Patch,Delete), groupesrole:readetrole:write, filtreisSystem. - Ajouter un processor
RoleProcessordecorantPersistProcessoretRemoveProcessorpour :- refuser la suppression d'un role systeme en traduisant
SystemRoleDeletionExceptionen403, - empecher la mutation de
codeetisSystemsur un role systeme existant.
- refuser la suppression d'un role systeme en traduisant
- Ajouter une operation nommee
user_rbac_patch(PATCH /api/users/{id}/rbac) sur l'entiteUseravec son propre groupeuser:rbac:writeexposantisAdmin,rolesetdirectPermissions. Laisseruser:writepropre pour les champs profil (compatible avec la decision de0fc4e16). Le nom explicite est indispensable : API Platform 4 identifie les operations par nom, unnew Patchsansname:entrerait en collision avec l'operation profil existante. - Ajouter un processor
UserRbacProcessorqui persiste les mutations RBAC de l'utilisateur sans toucher au password hashing (decorator dePersistProcessor, pas duUserPasswordHasherProcessor). - Ajouter sur
Roleles contraintes Symfony Validator :UniqueEntity(fields: ['code']),Assert\NotBlanketAssert\Regexsurcode,Assert\NotBlanksurlabel(cf. section 6). - Garder toutes les operations sous
is_granted('ROLE_ADMIN')avec un commentaire// TODO ticket #345 : remplacer par is_granted('core.roles.manage'). - Tests PHPUnit unitaires (processors) et fonctionnels (
ApiTestCase) couvrant les chemins nominaux et les cas 403/422.
OUT
- Ticket
#345: voterPermissionVoter, remplacement duis_granted('ROLE_ADMIN')par les codes de permission, composable frontusePermissions. - Ticket
#346: ecrans d'administration front (liste/edition des roles et permissions). - Ticket
#347: UX des erreurs 403 et integration front de l'ecran de gestion des permissions utilisateur. - Endpoint d'ecriture sur
Permission: la table reste la propriete exclusive deapp:sync-permissions(source de verite = code). - Lecture des permissions effectives d'un
Uservia/api/me: traitee au #345 en meme temps que le voter. - Exposition d'un endpoint de bulk-assign permissions sur plusieurs utilisateurs : hors scope.
3. Fichiers a creer
Infrastructure - Processors
-
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.phpDecorator deApiPlatform\Doctrine\Common\State\PersistProcessoretRemoveProcessor. Charge de la gardeensureDeletable()et de la protection des champs immuables sur un role systeme. -
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.phpDecorator dePersistProcessorspecifique a l'operationPATCH /api/users/{id}/rbac. Persiste les mutationsisAdmin,roles,directPermissionssans passer parUserPasswordHasherProcessor.
Tests unitaires
/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php
Tests fonctionnels
/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php
4. Fichiers a modifier
Entite Permission
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php
- Ajouter l'attribut
#[ApiResource]avec operationsGetCollection+Getuniquement. - Normalization context : groupe
permission:readuniquement. - Pas de
denormalizationContext(lecture seule). - Security
is_granted('ROLE_ADMIN')sur les deux operations (TODO #345). - Ajouter
#[Groups(['permission:read'])]sur$id,$code,$label,$module,$orphan. Pas d'ajout du grouperole:read: on laisse API Platform serialiser la relationRole::$permissionsen IRIs par defaut, le front resoudra les details en 2 appels si necessaire (decision explicite pour garder les payloads petits et les permissions paginable independamment). - Ajouter les filtres API Platform
SearchFiltersurmodule(exact) etBooleanFiltersurorphan.
Extrait attendu :
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['permission:read']],
),
new Get(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['permission:read']],
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
Entite Role
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php
- Ajouter l'attribut
#[ApiResource]avec operationsGetCollection,Get,Post,Patch,Delete. - Normalization context :
role:read. Denormalization context :role:write. - Processor
RoleProcessor::classsurPost,PatchetDelete. - Security
is_granted('ROLE_ADMIN')sur les 5 operations (TODO #345). - Groupes :
$id:role:read.$code:role:read,role:write. L'immuabilite apres creation est portee parRoleProcessor(variante A, cf. section 5), pas par un decoupage de groupes.$label:role:read,role:write.$description:role:read,role:write.$isSystem:role:read(jamais writable via API).$permissions:role:read,role:write. Serialise en IRIs (comportement API Platform par defaut sur une relation ManyToMany).
- Filtre
BooleanFiltersurisSystem. - Important : le constructeur actuel
public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)doit etre compatible avec la denormalisation API Platform surPOST. API Platform 4 resout les arguments du constructeur par nom de propriete denormalise. Verifier (ou adapter) queisSystemne peut pas etre injecte par le POST car il n'est pas dansrole:write.
Entite User
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php
- Ajouter dans la liste des operations
ApiResourceexistantes une operation dediee :
new Patch(
name: 'user_rbac_patch',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
Le name: est OBLIGATOIRE : sans lui, API Platform 4 deduit un nom par defaut qui peut collisionner avec la Patch profil existante (meme classe, meme methode HTTP) et provoquer un ecrasement silencieux de la route /api/users/{id}.
- Ajouter le groupe
user:rbac:writesur les proprietes :$isAdmin$roles$directPermissions
- Ne PAS toucher
user:write: la decision de0fc4e16est confirmee par ce ticket.
Raison de l'endpoint dedie (option B) :
- Separation des preoccupations : un
PATCH /api/users/{id}reste un endpoint "profil" ; la promotion admin et la gestion des permissions est un acte administratif explicite et tracable. - Facilite future l'ajout d'un audit log dedie (
#355audit log project) sur l'endpoint RBAC sans polluer l'audit profil. - Contrat front simple : une seule route, un seul groupe, une seule validation.
5. Regles metier et cas limites
Role
-
Creation (
POST /api/roles) :code,labelobligatoires.descriptionoptionnel.permissionsoptionnel (tableau d'IRIs).isSystemest toujoursfalsepour les roles crees via API (n'est pas dansrole:write).- Unicite du
codegeree par la contrainte DBuniq_role_code→ 422 viaUniqueEntityvalidator a ajouter sur l'entite (voir section 6).
-
Modification (
PATCH /api/roles/{id}) :label,description,permissionsmodifiables librement, y compris sur un role systeme (utile pour customiser l'apparence dans l'UI sans casser la relation).codeimmuable apres creation — strategie retenue (variante A) : un seul grouperole:writecontenantcode, et une garde centralisee dansRoleProcessor. Le processor compare la valeur entrante a l'etat d'origine viaUnitOfWork::getOriginalEntityData($role)['code']; si elle differe, leveBadRequestHttpExceptionavec un message francais explicite. Regle unique et uniforme : roles systeme ET roles customs sont concernes. Justification : garder la regle metier dans le domaine applicatif plutot que dupliquer les groupes de serialisation.
-
Suppression (
DELETE /api/roles/{id}) :RoleProcessorappelle$role->ensureDeletable()avant de deleguer auRemoveProcessor.SystemRoleDeletionExceptionest catchee et re-levee enSymfony\Component\HttpKernel\Exception\AccessDeniedHttpException(403).- Les relations
user_roleetrole_permissionsur ce role sont nettoyees automatiquement par leON DELETE CASCADEdes contraintesFK_2DE8C6A3D60322AC(user_role.role_id) etFK_6F7DF886D60322AC(role_permission.role_id) posees dansmigrations/Version20260414150034.php. Aucun nettoyage manuel necessaire dansRoleProcessor. Verifier en test fonctionnel par un DELETE d'un role custom attache a un user, puis assert que le user existe toujours et queuser_roleest vide pour ce couple.
Permission
- Lecture seule via API. Aucun endpoint de mutation.
- Si un admin veut forcer une permission sur un utilisateur, il passe par
directPermissionsdeUser.
User (operation RBAC)
PATCH /api/users/{id}/rbacn'accepte queisAdmin,roles,directPermissions. Tout autre champ dans le payload est ignore (comportement par defaut d'API Platform avec undenormalizationContextrestreint).- Garde minimale auto-suicide :
UserRbacProcessorrefuse (BadRequestHttpException400) toute requete ou l'user cible est egal a l'user courant duSecurity::getUser()ETisAdminpasse detrueafalse. Sans cette garde, un admin peut se degrader seul et perdre acces a l'endpoint, creant une situation de recovery penible. C'est une garde locale et pragmatique, volontairement plus stricte que "le dernier admin" : on interdit l'auto-degradation, point. La garde "plus d'un admin restant" reste reportee au #345 ou un inventaire global fera sens avec le voter. TODO a placer dans le processor avec reference a #345. - Le password n'est jamais touche par cet endpoint (contrairement a
UserPasswordHasherProcessorsurPATCH /api/users/{id}).
6. Validation
- Ajouter sur
Roleune contrainte#[UniqueEntity(fields: ['code'])]pour un 422 propre au lieu d'un 500 SQL en cas de conflit. - Ajouter sur
Role::$codeun#[Assert\NotBlank]et un#[Assert\Regex('/^[a-z][a-z0-9_]*$/')](meme convention que les permissions). - Ajouter sur
Role::$labelun#[Assert\NotBlank].
7. Plan de tests
Unitaires
RoleProcessorTest
process()d'un role non-systeme en DELETE delegue auRemoveProcessorsans lever.process()d'un role systeme en DELETE leveAccessDeniedHttpException(403) et n'appelle pas le decorator.process()d'un role systeme en PATCH dont lecodea change leveBadRequestHttpException.process()d'un role systeme en PATCH dont seulslabel/permissionschangent delegue auPersistProcessor.process()d'un role non-systeme en POST delegue auPersistProcessor.
UserRbacProcessorTest
process()persiste un user avecisAdmin = truevia le decorator.process()persiste une collection derolesmise a jour.process()ne declenche jamais le hashing de password (verifier queUserPasswordHasherProcessorn'est pas dans la chaine).
Fonctionnels (ApiTestCase)
PermissionApiTest
GET /api/permissionsen tant qu'admin retourne la liste des permissions synchronisees.GET /api/permissions?module=corefiltre par module.GET /api/permissions?orphan=trueretourne uniquement les orphelines.GET /api/permissions/{id}retourne les champs attendus (groupepermission:read).POST /api/permissionsen tant qu'admin retourne405 Method Not Allowed.GET /api/permissionsnon authentifie retourne401.GET /api/permissionsen tant que user standard retourne403.
RoleApiTest
POST /api/rolesavec{code, label, description}retourne201et persisteisSystem = false.POST /api/rolesavec uncodedeja utilise retourne422.POST /api/rolesavec uncodeinvalide (MAJ,space) retourne422.PATCH /api/roles/{id}sur un role custom modifielabelet ajoute des permissions via IRIs →200.PATCH /api/roles/{id}sur le roleadmin(systeme) modifiant seulementlabel→200.PATCH /api/roles/{id}sur le roleadmintentant de modifiercode→400.DELETE /api/roles/{id}sur un role custom →204.DELETE /api/roles/{id}sur le roleadmin→403avecSystemRoleDeletionExceptiontraduite.DELETE /api/roles/{id}d'un role custom attache a un user : le user reste, la relationuser_roleest nettoyee par le CASCADE.- Toute operation sans auth retourne
401. - Toute operation en tant que user standard retourne
403.
UserRbacApiTest
PATCH /api/users/{id}/rbacen tant qu'admin avec{isAdmin: true}promeut le user.PATCH /api/users/{id}/rbacavec{roles: [IRI...]}remplace la collection de roles RBAC.PATCH /api/users/{id}/rbacavec{directPermissions: [IRI...]}remplace les permissions directes.PATCH /api/users/{id}/rbacen tant que user standard retourne403.PATCH /api/users/{id}/rbacnon authentifie retourne401.PATCH /api/users/{id}/rbacavec un champusernamedans le payload n'est pas persiste (denormalization context restreint).PATCH /api/users/{id}sans/rbacavec{isAdmin: true}ne modifie PASisAdmin(confirme la decision0fc4e16).
8. Securite et traduction d'exceptions
SystemRoleDeletionException→AccessDeniedHttpException(403) dansRoleProcessor(pas via un listener global : on garde la traduction locale au perimetre RBAC).BadRequestHttpExceptionpour la mutation decodesur un role systeme : message explicite en francais, dans le payload Hydrahydra:description.- Toutes les routes ont pour l'instant
security: "is_granted('ROLE_ADMIN')". Un commentaire// TODO ticket #345doit etre present sur chaque attribut pour faciliter le remplacement.
9. Conventions et architecture
- Respect strict du modular monolith : tous les fichiers crees vivent dans
src/Module/Core/outests/Module/Core/. Aucun import depuis un autre module. declare(strict_types=1)en tete des nouveaux fichiers.- Commentaires PHP en francais, identifiants anglais (
CLAUDE.md). - Processors branches via l'autoconfiguration Symfony ; aucun wiring manuel dans
services.yamlattendu si le constructeur est injecte proprement. - Pattern de decorator : utiliser
#[AsDecorator]ou#[Autoconfigure]pour brancher le processor en tant que decorator duPersistProcessorAPI Platform, selon le pattern deja utilise parUserPasswordHasherProcessor. - Aucune nouvelle entree necessaire dans
config/modules.phpniconfig/sidebar.php.
10. Ordre d'execution recommande
- Ajouter l'attribut
#[ApiResource]et les#[Groups]surPermission. EcrirePermissionApiTest. - Ajouter les contraintes Validator sur
Role. Ajouter#[ApiResource]et les#[Groups]surRolesans processor dans un premier temps pour valider le CRUD nominal. - Creer
RoleProcessoret le brancher en decorator. Ajouter les gardes systeme. EcrireRoleProcessorTest+ casRoleApiTest. - Creer
UserRbacProcessor. Ajouter l'operation/users/{id}/rbacet le groupeuser:rbac:writesurUser. EcrireUserRbacProcessorTest+UserRbacApiTest. make testcomplet +make php-cs-fixer-allow-risky.- Documentation : referencer ce spec dans
docs/rbac/et mettre a jour le fil conducteur RBAC si un index existe.
11. Risques et points d'attention
- Constructeur de
Roleet denormalisation POST : API Platform 4 resout les arguments du constructeur par nom ;isSystemest dans la signature mais pas dansrole:write, donc un client ne peut pas l'injecter — a verifier par un test explicite ("POST avecisSystem: trueest ignore"). codeimmuable : strategie retenue (garde dans processor) simple mais demande une lecture de l'etat initial du role avant persistance. UtiliserUnitOfWork::getOriginalEntityData()pour recuperer la valeur d'origine proprement.- Cascade de delete role → user_role : depend de
ON DELETE CASCADEpose par la migration #343. Verifier explicitement en test fonctionnel qu'aucuneForeignKeyConstraintViolationExceptionne remonte. UniqueEntitysurcode: ne couvre pas les conflits en race condition, la DB reste la garde ultime. Acceptable.- Pas de filtre sur le
modulede Permission cote front au #346 sans le filtre API : s'assurer que le filtre est bien pose ici. - Auto-retrait du dernier admin : garde d'auto-suicide posee dans
UserRbacProcessor(un admin ne peut pas se degrader lui-meme, cf. section 5). La garde "plus d'un admin restant" au niveau global reste reportee au voter #345. - Infra de test fonctionnel (fixtures et isolation) : les tests
*ApiTestdependent de la presence en base des roles systemeadminetuser. L'infra actuelle doit fournir soit un reload des fixtures par classe de test, soitDAMADoctrineTestBundlepour transactionner chaque test. A verifier au debut de l'etape 1 de l'ordre d'execution ; si absent, ajouter un trait de bootstrap minimalRbacFixturesTraitqui insere les deux roles systeme avant chaque classe de test (pas par test, trop couteux). Ne pas bloquer le ticket sur cette question, adapter au vol.
12. Criteres d'acceptation (DoD)
GET /api/permissionsetGET /api/permissions/{id}fonctionnent, filtresmoduleetorphanoperationnels.- CRUD complet sur
/api/rolesoperationnel, avecisSystemen lecture seule cote API. DELETE /api/roles/{admin_id}retourne403avec un message metier.PATCH /api/roles/{admin_id}autorise la modification delabel/permissionsmais refuse la modification decodeavec400.PATCH /api/users/{id}/rbacpermet de modifierisAdmin,rolesetdirectPermissions;PATCH /api/users/{id}(profil) ne les modifie jamais.- Les operations API sont gardees par
is_granted('ROLE_ADMIN')et commentees avec la TODO pointant vers #345. make testpasse ;make php-cs-fixer-allow-riskyne laisse aucun delta.- Aucun import croise entre modules ; tous les fichiers crees vivent dans
Module/Core/outests/Module/Core/. - Le spec est mergee avec le code (meme PR ou PR precedente) pour rester la reference du ticket.
13. Remarques de branche
- Le ticket enonce "Branche a creer :
feat/rbac-apidepuis develop apres merge de #2". - Branche courante locale :
feat/rbac-core. A confirmer avec l'utilisateur si PR #2 est mergee : si oui, se rebaser surdevelopet creerfeat/rbac-apipropre ; sinon, empilerfeat/rbac-apisurfeat/rbac-coreen attendant le merge.