## 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>
32 KiB
Ticket #345 - 3/5 - Voter Symfony + composable usePermissions (Full-stack)
1. Objectif
Ce ticket remplace les gardes placeholder is_granted('ROLE_ADMIN') posees par le #344 sur les 13 operations API Platform du perimetre RBAC par des verifications metier basees sur les codes de permission livres au #343 (core.users.view, core.roles.manage, etc.). Il introduit le PermissionVoter Symfony qui interprete ces codes, avec un bypass total pour les utilisateurs isAdmin = true (decision gravee au #343 section 11). Il ferme la garde "dernier admin global" reportee par le #344 via un service domaine mutualise entre les chemins de mutation (PATCH /users/{id}/rbac et DELETE /users/{id}). Enfin il expose les permissions effectives de l'utilisateur courant via /api/me et livre le composable front usePermissions() qui les consomme.
A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applicatif reel, utilisable par les tickets #346 (ecrans d'admin RBAC) et #347 (UX des erreurs 403). Aucune interface d'administration n'est livree ici : le ticket est un socle full-stack sans ecran dedie.
2. Perimetre
IN
- Ajouter la permission
core.roles.viewau catalogueCoreModule::permissions()et la synchroniser viaapp:sync-permissions. Documenter la regle par defaut "view + manage par ressource administrable" qui encadre les declarations futures. - Creer
PermissionVoterSymfony qui :- supporte les attributs au format
module.resource[.sub].action(regex explicite) sans interferer avecROLE_*, - bypasse a
ACCESS_GRANTEDsiUser::isAdmin() === true, - sinon compare l'attribut a
User::getEffectivePermissions().
- supporte les attributs au format
- Remplacer les 13
is_granted('ROLE_ADMIN')places par le #344 (et les operations User heritees du profil pre-#344) par les codes metier adequats sur les entitesPermission,RoleetUser. Supprimer les commentaires// TODO ticket #345en meme temps. - Creer un service domaine
AdminHeadcountGuarddanssrc/Module/Core/Domain/Security/qui encapsule la regle "il doit toujours rester au moins un administrateur sur l'instance" et leveLastAdminProtectionExceptionquand l'operation ferait tomber le compteur a zero. - Brancher le guard dans
UserRbacProcessor(apres la garde auto-suicide existante) et dans un nouveauUserProcessordecorateur deRemoveProcessorqui intercepteDELETE /api/users/{id}. - Ajouter
UserRepositoryInterface::countAdmins(): intet son implementation Doctrine. - Enrichir
/api/meen exposanteffectivePermissions: list<string>via un#[Groups(['me:read'])]sur la methode existanteUser::getEffectivePermissions(). Aucun changement deMeProvider. - Livrer
frontend/shared/composables/usePermissions.tsconsommantuseAuthStore().user(qui porte deja le payload/api/me). API publique :can(code),canAny(codes),canAll(codes). - Etendre
frontend/shared/types/user-data.tsavec les champsisAdmin: booleaneteffectivePermissions: string[]. - Tests unitaires PHP :
PermissionVoterTest,AdminHeadcountGuardTest,UserProcessorTest, extension deUserRbacProcessorTest. - Tests fonctionnels API : couverture 403 non-admin / 200 admin sur chaque operation des 3 ressources RBAC, cas "dernier admin global" sur PATCH et DELETE, expo
/api/meaveceffectivePermissions. - Test Vitest du composable
usePermissions.
OUT
- Ticket
#346: ecrans d'administration RBAC front (liste/edition roles, picker permissions, admin user RBAC). - Ticket
#347: UX des erreurs 403 (toasts, redirections, page 403 dediee), integration front complete des ecrans admin RBAC. - Decoration des items sidebar par permission : les items portent aujourd'hui un champ
moduleowner ; le filtrage par permission individuelle sera ajoute au #346 quand l'UI en aura besoin. - Audit log des mutations RBAC : traite par le futur
#355audit log project, deliberement independant. - Decoupe fine de
core.users.manageen sous-permissions (create,edit,delete) : YAGNI, aucun use-case metier identifie a ce jour. - Cache des voter decisions : la verification est O(1) sur un
in_arrayavec des collections dejafetch=EAGER, aucun cache necessaire.
3. Fichiers a creer
Domaine - Securite
-
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.phpService domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement deUserRepositoryInterface::countAdmins(). Aucune dependance infrastructure, testable en isolation. -
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.phpException metier levee par le guard. Traduite enBadRequestHttpException(400) dans les processors.
Infrastructure - Security
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.phpVoter Symfony etendantSymfony\Component\Security\Core\Authorization\Voter\Voter. Decouvert automatiquement parautoconfigure: true.
Infrastructure - Processors
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.phpDecorateur deRemoveProcessorcible surDELETE /api/users/{id}. AppelleAdminHeadcountGuardavant de deleguer. Meme pattern qu'UserRbacProcessor/RoleProcessor:final class,#[Autowire]sur l'inner,LogicExceptionfail-fast si le type entrant n'est pasUser.
Frontend - Composable
/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.tsComposable stateless qui lituseAuthStore().user. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
Tests unitaires PHP
/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php
Tests fonctionnels PHP
/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php(si absent — sinon extension) Couvre l'enrichissement du payload/api/me./home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserApiTest.php(si absent — sinon extension) Couvre la garde "dernier admin global" surDELETE /api/users/{id}.
Tests frontend
/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.tsVitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier.spec.ts, ou repertoiretests/). A verifier au debut de la task frontend.
4. Fichiers a modifier
CoreModule.php
/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php
Ajouter une cinquieme entree au catalogue :
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
];
}
La commande app:sync-permissions creera automatiquement core.roles.view a la prochaine execution, sans migration Doctrine necessaire (le catalogue est propriete exclusive de la commande de sync depuis le #343).
Entite Permission
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php
Remplacer les 2 gardes placeholder :
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view')",
),
new Get(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view')",
),
Supprimer les commentaires // TODO ticket #345.
Entite Role
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php
Remplacer les 5 gardes placeholder :
GetCollection→is_granted('core.roles.view')Get→is_granted('core.roles.view')Post→is_granted('core.roles.manage')Patch→is_granted('core.roles.manage')Delete→is_granted('core.roles.manage')
Supprimer les commentaires // TODO ticket #345.
Entite User
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php
Remplacer les 6 gardes ROLE_ADMIN restantes :
Get(item) →is_granted('core.users.view')GetCollection→is_granted('core.users.view')Post→is_granted('core.users.manage')Patch(profil, sansname:) →is_granted('core.users.manage')Patch(user_rbac_patch) →is_granted('core.users.manage')Delete→is_granted('core.users.manage')
Note : l'operation Get /me n'a aucune garde (seulement IS_AUTHENTICATED_FULLY implicite via security.yaml). Ce n'est pas une operation RBAC, elle reste inchangee.
Ajouter le processor UserProcessor::class sur l'operation Delete :
new Delete(
security: "is_granted('core.users.manage')",
processor: UserProcessor::class,
),
Exposer getEffectivePermissions() dans le groupe me:read — ajouter l'attribut sur la methode existante :
#[Groups(['me:read'])]
public function getEffectivePermissions(): array
{
// implementation existante, inchangee
}
Supprimer tous les commentaires // TODO ticket #345 rencontres.
UserRepositoryInterface
/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php
Ajouter la methode :
/**
* Compte le nombre d'utilisateurs avec le flag isAdmin = true.
* Utilise par AdminHeadcountGuard pour verifier l'invariant
* "au moins un administrateur reste sur l'instance".
*/
public function countAdmins(): int;
DoctrineUserRepository
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
Implementer countAdmins() via un QueryBuilder simple :
public function countAdmins(): int
{
return (int) $this->createQueryBuilder('u')
->select('COUNT(u.id)')
->where('u.isAdmin = true')
->getQuery()
->getSingleScalarResult();
}
UserRbacProcessor
/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php
Ajouter la dependance AdminHeadcountGuard et l'invoquer apres la garde auto-suicide existante, avant de deleguer au persist processor. Supprimer le TODO ticket #345 du docblock.
Logique :
1. Garde auto-suicide existante (inchangee).
2. Si l'operation entraine la perte du flag isAdmin (wasAdmin && !data.isAdmin):
AdminHeadcountGuard::ensureAtLeastOneAdminRemainsAfterDemotion($data);
3. Delegation au persist processor.
La detection "wasAdmin && !data.isAdmin" reutilise le meme UnitOfWork::getOriginalEntityData() deja utilise par la garde auto-suicide.
frontend/shared/types/user-data.ts
Ajouter les champs :
export interface UserData {
id: number
username: string
isAdmin: boolean
effectivePermissions: string[]
// ... champs existants
}
frontend/shared/services/auth.ts
A verifier : si getCurrentUser() type deja le retour sur UserData, rien a changer — les nouveaux champs arrivent automatiquement car l'API les renvoie. Si un mapping manuel est fait dans le service, l'etendre pour ne pas perdre isAdmin et effectivePermissions. A valider au debut de la task frontend.
5. PermissionVoter - details d'implementation
Regex de support
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
Garantit :
- premier caractere alphabetique minuscule,
- au moins un point de separation (ecarte les
ROLE_*), - segments en snake_case minuscules coherents avec les permissions declarees par les modules.
supports(string $attribute, mixed $subject): bool
Retourne (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute). Le $subject est ignore : les permissions sont portees par l'utilisateur, pas par une ressource ciblee. Pour l'instant l'autorisation est uniquement basee sur l'identite de l'acteur — les scopes ressource (ex. "edit this specific role") seront traites par un voter dedie si un module metier en a besoin.
voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
$user = $token->getUser()
if (!$user instanceof User) return false // ACCESS_DENIED
if ($user->isAdmin()) return true // bypass total
return in_array($attribute, $user->getEffectivePermissions(), true)
Interaction avec les autres voters
Strategie par defaut Symfony affirmative : des qu'un voter renvoie GRANTED, l'acces est accorde. PermissionVoter ne vote jamais sur les attributs ROLE_* (filtres par supports()), donc :
- l'authentification classique
IS_AUTHENTICATED_FULLYetROLE_USERcontinue de fonctionner viaAuthenticatedVoteretRoleVoterde Symfony, - un eventuel
is_granted('ROLE_ADMIN')residuel dans le code continuerait de fonctionner viaRoleVotersans interference.
Un test fonctionnel make test complet verifiera que l'auth standard marche toujours apres ajout du voter.
Wiring
autoconfigure: true dans services.yaml (deja active) detecte la classe via l'interface VoterInterface. Aucun wiring manuel necessaire dans services.yaml.
6. AdminHeadcountGuard - regles metier
Invariant global
Apres toute operation terminee avec succes,
countAdmins() >= 1.
API publique
final class AdminHeadcountGuard
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
) {}
/**
* Leve si retirer le flag isAdmin a $user ferait tomber le total a zero.
* A appeler UNIQUEMENT dans la branche "l'operation retire effectivement isAdmin".
*/
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
/**
* Leve si supprimer physiquement $user ferait tomber le total a zero.
* A appeler UNIQUEMENT dans la branche DELETE sur un user admin.
*/
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
}
Deux methodes semantiques distinctes plutot qu'une methode generique avec un parametre booleen : ca rend les call-sites lisibles et les tests auto-documentes.
Logique
Pour les deux methodes, la regle effective est identique :
if ($this->userRepository->countAdmins() <= 1) {
throw new LastAdminProtectionException(
'Impossible : au moins un administrateur doit rester sur l\'instance.'
);
}
Les appelants ne passent le guard que si l'operation retire reellement un admin — le guard n'a donc pas a raisonner sur l'etat entrant. Cette separation des responsabilites (le processor decide "est-ce qu'on perd un admin ?", le guard applique "si oui, compte") garde les deux composants minimalistes et testables independamment.
Cas couverts (tests)
countAdmins() > 1+ demotion → OK (pas d'exception)countAdmins() == 1+ demotion → LEVEcountAdmins() > 1+ deletion → OKcountAdmins() == 1+ deletion → LEVEcountAdmins() == 2+ demotion → OK (il en reste 1)countAdmins() == 0+ demotion → LEVE (cas theorique, garde defensive)
7. Garde "dernier admin" - cohabitation avec l'auto-suicide
Les deux gardes sont distinctes et non fusionnables :
- Auto-suicide (existante, #344) : "un admin ne peut pas retirer ses PROPRES droits admin". S'applique meme s'il existe d'autres admins. Protege contre le recovery penible d'un admin qui se cliquerait degrade tout seul.
- Dernier admin global (nouveau, #345) : "l'instance doit toujours avoir au moins un admin". S'applique meme si ce n'est pas l'operation d'un admin sur lui-meme (admin A degrade admin B alors qu'ils sont les deux seuls).
Ordre d'evaluation dans UserRbacProcessor :
1. Garde auto-suicide (cas particulier, message dedie)
2. Garde dernier admin global (cas general, message dedie)
3. Persist
Les messages d'erreur distincts aident le front a afficher le bon feedback utilisateur. Le test UserRbacProcessorTest doit couvrir les deux branches.
Cas limite : l'admin se degrade lui-meme ET il est le dernier
Les deux gardes s'appliqueraient. Comme auto-suicide est evalue en premier, c'est son message qui est retourne ("Vous ne pouvez pas retirer vos propres droits administrateur."). Comportement acceptable et coherent : le user voit d'abord la regle la plus specifique.
8. /api/me enrichi - contrat
Payload avant :
{
"@context": "/api/contexts/User",
"@id": "/api/users/5",
"@type": "User",
"id": 5,
"username": "admin",
"isAdmin": true
}
Payload apres :
{
"@context": "/api/contexts/User",
"@id": "/api/users/5",
"@type": "User",
"id": 5,
"username": "admin",
"isAdmin": true,
"effectivePermissions": [
"core.permissions.view",
"core.roles.manage",
"core.roles.view",
"core.users.manage",
"core.users.view"
]
}
Contrat :
effectivePermissionsest toujours un tableau de strings (jamaisnull).- L'ordre est deterministe (trie alphabetique — implementation existante du #343).
- Aucun doublon.
- Pour un admin, le tableau contient les permissions effectives (non vides si le role
admina des permissions OU si l'user a des directPermissions, vide sinon). Le bypass ne se refletera PAS dans ce tableau :isAdmin: truereste la source de verite du bypass. Le front l'utilise en priorite dans le composable.
Pourquoi le bypass n'est pas materialise dans effectivePermissions
Mettre "toutes les permissions connues" dans le tableau pour les admins serait tentant mais faux :
- il faudrait enumerer dynamiquement toutes les permissions de tous les modules actifs, ce qui recouvre la responsabilite de
app:sync-permissions, - le tableau gonflerait inutilement le payload
/api/mea chaque requete, - et surtout il deviendrait faux si un module declare une nouvelle permission apres une execution de sync : l'admin aurait temporairement un tableau incomplet alors que son bypass reste effectif.
La source de verite du bypass est isAdmin: boolean. Le composable front regarde ce flag en premier.
9. usePermissions - composable front
API publique
export function usePermissions() {
const auth = useAuthStore()
// Verifie si l'utilisateur courant a la permission demandee.
// Bypass automatique si isAdmin = true, coherent avec PermissionVoter cote back.
const can = (code: string): boolean => {
const user = auth.user
if (!user) return false
if (user.isAdmin) return true
return user.effectivePermissions.includes(code)
}
const canAny = (codes: string[]): boolean => codes.some(can)
const canAll = (codes: string[]): boolean => codes.every(can)
return { can, canAny, canAll }
}
Proprietes
- Stateless : aucun
refmodule-level, aucune reactivite dediee. Tout passe paruseAuthStore().userqui est deja reactif via Pinia. - Aucun fetch propre : les permissions arrivent par
/api/meau login (viauseAuthStore().ensureSession()ou.login()), aucun appel supplementaire n'est necessaire. - Aucun reset : le logout efface deja
authStore.user, donccan()retombe naturellement afalse. - Bypass synchrone avec le back : la regle
if (user.isAdmin) return trueduplique deliberement le bypass duPermissionVotercote back. Commentaire francais dans le composable pour rappeler que les deux doivent bouger ensemble si la regle change un jour.
Pas de variante can reactive (computed)
Utiliser computed(() => can('core.users.view')) dans un composant fonctionne automatiquement puisque auth.user est reactif Pinia — Vue re-evalue le computed quand user change. Pas besoin d'API supplementaire du composable pour ca.
10. Validation
Aucune nouvelle contrainte Symfony Validator introduite par ce ticket. Les gardes metier (AdminHeadcountGuard, SystemRoleDeletionException, auto-suicide) vivent dans les processors et le domaine, pas dans la couche Validator.
11. Plan de tests
Unitaires PHP
PermissionVoterTest
supports('core.users.view')retournetrue.supports('ROLE_ADMIN')retournefalse(n'interfere pas avec les voters core).supports('IS_AUTHENTICATED_FULLY')retournefalse.supports('invalid attribute')retournefalse(espace, majuscule).voteOnAttributeavec unUseradmin retourne GRANTED quelle que soit la permission.voteOnAttributeavec un user portant la permission retourne GRANTED.voteOnAttributeavec un user ne portant pas la permission retourne DENIED.voteOnAttributeavec un token non-authentifie (user null) retourne DENIED.
AdminHeadcountGuardTest
ensureAtLeastOneAdminRemainsAfterDemotion:countAdmins == 2→ OK.- Meme methode :
countAdmins == 1→LastAdminProtectionException. - Meme methode :
countAdmins == 0→ leve aussi (garde defensive). ensureAtLeastOneAdminRemainsAfterDeletion: memes 3 cas, memes resultats.UserRepositoryInterface::countAdmins()est mockee avec une valeur fixe pour chaque cas (test unitaire isole, pas d'acces BDD).
UserProcessorTest
process()sur un user non-admin en DELETE delegue auRemoveProcessor.process()sur un user admin en DELETE aveccountAdmins() > 1delegue.process()sur un user admin en DELETE aveccountAdmins() == 1leveBadRequestHttpException(traduction deLastAdminProtectionException).process()avec$datanon-UserleveLogicException(fail-fast coherent avecUserRbacProcessor/RoleProcessor).
UserRbacProcessorTest (extension)
- Cas existants auto-suicide : gardes en l'etat.
- Nouveau : PATCH RBAC par admin A sur admin B,
isAdmin: false,countAdmins() == 1(apres perte = 0) →BadRequestHttpException"dernier admin". - Nouveau : meme operation avec
countAdmins() == 2→ delegue au persist processor. - Nouveau : PATCH RBAC qui ne touche pas
isAdmin(change justerolesoudirectPermissions) ne consulte jamais le guard, meme sicountAdmins() == 1.
Fonctionnels API PHP (AbstractApiTestCase)
Pour les 3 ressources (Permission, Role, User), pour chaque operation, 3 cas :
- Admin → succes (confirme que le voter bypass fonctionne).
- User standard avec la permission requise (attachee via fixture dediee) → succes.
- User standard sans la permission →
403.
Fixtures de test : ajouter des users "portant une permission specifique" n'est pas souhaitable dans AppFixtures (fixtures de dev). Creer a la place un trait ou une helper method AbstractApiTestCase::createUserWithPermission(string $code): User qui instancie a la volee un user + un role + l'attache dans le test lui-meme, transactionne si DAMADoctrineTestBundle est en place.
Cas specifiques a ajouter :
UserRbacApiTest: PATCH/api/users/{lastAdminId}/rbacavecisAdmin: falsepar un autre admin →400avec message "dernier admin" (et pas "auto-suicide").UserApiTest(nouveau ou extension) : DELETE/api/users/{lastAdminId}par un autre admin →400avec message "dernier admin".UserApiTest: DELETE/api/users/{nonAdminId}fonctionne quel que soit le count (la garde ne doit pas etre appelee).MeApiTest:GET /api/meen tant qu'admin retourneeffectivePermissions(tableau, meme vide si pas de role populaire).MeApiTest:GET /api/meen tant que user standard retourneeffectivePermissions= list triee des codes issus de ses roles et directPermissions.
Tests frontend (Vitest)
usePermissions.test.ts
- Utilisateur null →
can()retournefalsepour n'importe quel code. - Utilisateur admin →
can('core.users.view')retournetruememe sieffectivePermissionsest vide. - Utilisateur non-admin avec
['core.users.view']→can('core.users.view')=true,can('core.users.manage')=false. canAny(['a', 'b'])retournetruesi l'un des deux matche,falsesinon.canAll(['a', 'b'])retournetrueuniquement si les deux matchent.
Convention de test frontend a valider avant : si le projet Nuxt a deja un setup Vitest, on s'y aligne ; sinon on note une TODO pour ajouter la conf (sans bloquer le ticket — le composable est assez simple pour etre revu manuellement).
12. Securite et traduction d'exceptions
LastAdminProtectionException(domaine) →BadRequestHttpException(400) dans les processors. Message francais : "Impossible : au moins un administrateur doit rester sur l'instance."SystemRoleDeletionException(existante) → traduction inchangee par le #344, rien a modifier.- Auto-suicide existante → message inchange : "Vous ne pouvez pas retirer vos propres droits administrateur."
- Pas de listener global : traduction locale dans chaque processor, coherent avec le pattern du #344.
13. Conventions et architecture
- Respect strict du modular monolith : tous les fichiers crees vivent dans
src/Module/Core/,tests/Module/Core/, oufrontend/shared/. Aucun import inter-modules. declare(strict_types=1)en tete de tous les nouveaux fichiers PHP.- Commentaires PHP et TS en francais, identifiants en anglais (
CLAUDE.md). - Autoconfigure Symfony detecte
PermissionVoterviaVoterInterface.AdminHeadcountGuardest autowire via son constructeur standard. - Les processors suivent le pattern du #344 :
final class,#[Autowire]sur l'inner,LogicExceptionfail-fast sur type invalide. - Aucune entree necessaire dans
config/modules.phpniconfig/sidebar.php. - Aucune migration Doctrine : le catalogue de permissions est synchronise par
app:sync-permissions(commande existante #343), pas par une migration.
14. Ordre d'execution recommande (subagent-driven)
- Catalogue — ajouter
core.roles.viewdansCoreModule::permissions(). Executerapp:sync-permissionsen local pour verifier l'ajout. Pas de test propre (couvert indirectement par les tests sync existants du #343). - Guard domaine — creer
LastAdminProtectionException, ajouterUserRepositoryInterface::countAdmins()+ impl Doctrine, creerAdminHeadcountGuard. EcrireAdminHeadcountGuardTest. - PermissionVoter — implementation +
PermissionVoterTest. Verifier viamake testque l'auth standard reste verte (aucune regression surROLE_*). - UserProcessor DELETE — creer le processor, wire sur l'operation
DeletedeUser. EcrireUserProcessorTest. - UserRbacProcessor extension — injecter
AdminHeadcountGuard, brancher apres la garde auto-suicide. EtendreUserRbacProcessorTestavec les nouveaux cas. - Remplacement des 13 gardes ROLE_ADMIN — modifier
Permission,Role,User. Supprimer tous les// TODO ticket #345. /api/meenrichi — ajouter#[Groups(['me:read'])]surgetEffectivePermissions(). Creer ou etendreMeApiTest.- Tests fonctionnels RBAC complets — helper
createUserWithPermission()dansAbstractApiTestCase, puis couverture 403 non-admin / 200 avec permission sur toutes les operations RBAC des 3 ressources. Cas "dernier admin global" PATCH et DELETE. - Frontend types + composable — etendre
UserData, creerusePermissions.ts, ecrire le test Vitest. - Verification finale —
make testvert,make php-cs-fixer-allow-riskysans delta, build Nuxt OK si modifie.
Chaque etape doit etre revue (spec compliance + code quality) avant de passer a la suivante, pattern subagent-driven-development retenu pour le #344.
15. Risques et points d'attention
- Ordre des voters Symfony :
PermissionVoterne vote jamais surROLE_*grace au regex de support. Risque quasi-nul d'interference avecRoleVoter/AuthenticatedVoter, a valider par un test fonctionnel/login_check+GET /api/meapres ajout du voter. - Serialisation de
getEffectivePermissions()via API Platform : la methode existe depuis le #343 mais n'a jamais ete sous serializer. Risque de rencontrer unReflectionExceptionsi le nom de propriete deduit ne matche pas (cas rare, API Platform gere les getters normalement). Mitigation : test fonctionnel/api/meen premiere validation. - Cout SQL de
countAdmins(): 1COUNT(*)par operation de mutation admin sensible. Index recommande suruser.is_admin(idx_user_is_admin) — a verifier si la migration #343 l'a deja cree. Si non, c'est un ajustement cosmetique qu'on peut reporter puisque la tableuserd'un CRM PME reste petite (< 1000 lignes). - Bypass front/back desynchronise : si un jour le bypass admin est affine cote back (ex: seulement sur certains modules), le composable front doit bouger en meme temps. Mitigation : commentaire francais explicite dans
usePermissions.tspointant vers cette spec. - Tests fonctionnels et fixtures RBAC : le #344 a introduit
AbstractApiTestCase, mais les users de test portant une permission specifique (hors admin/user standard) n'existent pas dans les fixtures. Creer une helpercreateUserWithPermission()transactionnelle dans la classe de test, plutot que polluerAppFixturesavec des users de test dedies. - Ordre d'evaluation auto-suicide vs dernier admin : les deux gardes pourraient etre declenchees simultanement (admin unique qui se degrade lui-meme). L'auto-suicide gagne en premier par design. A couvrir explicitement par un test.
- Payload
/api/meplus gros : l'ajout deeffectivePermissionsalourdit chaque requete/api/me. Pour 5 permissions aujourd'hui c'est negligeable, mais si le catalogue grossit fortement (50+ permissions reparties sur plusieurs modules), il faudra peut-etre filtrer cote serveur (ne retourner que les permissions utiles au contexte front). Hors scope, mais a noter pour suivi. UserDatapartagee entre auth store et composable : toute modification future de la shapeUserDatapeut impacterusePermissions. Rester minimal dans le composable et laisser Pinia porter la verite.
16. Criteres d'acceptation (DoD)
- Le catalogue
CoreModule::permissions()contient 5 entrees incluantcore.roles.view. PermissionVoterexiste, supporte uniquement les attributs au formatmodule.resource.action, bypass admin effectif, test unitaire complet.- Les 13 operations API Platform du perimetre RBAC sont toutes gardees par un code metier
core.*.*et plus parROLE_ADMIN. Les commentaires// TODO ticket #345ont disparu du code. AdminHeadcountGuardexiste comme service domaine, est consomme parUserRbacProcessorETUserProcessor, teste en isolation.UserRepositoryInterface::countAdmins()existe et est implementee.UserProcessorintercepteDELETE /api/users/{id}et bloque la suppression du dernier admin avec un message explicite.UserRbacProcessorbloque la demotion du dernier admin global (en plus de la garde auto-suicide existante) avec un message distinct.GET /api/meretourneeffectivePermissions: string[]etisAdmin: booleandans son payload.frontend/shared/composables/usePermissions.tsexposecan,canAny,canAll, stateless, bypasse siisAdmin.frontend/shared/types/user-data.tsinclutisAdmineteffectivePermissions.- Tests unitaires PHP :
PermissionVoterTest,AdminHeadcountGuardTest,UserProcessorTest, extensionUserRbacProcessorTest— tous verts. - Tests fonctionnels API : couverture 403 non-admin / 200 admin-ou-porteur sur chaque operation RBAC des 3 ressources, cas dernier admin PATCH et DELETE,
/api/meenrichi. - Test Vitest
usePermissions.test.tsvert (ou TODO documentee si setup Vitest absent du projet). make testpasse ;make php-cs-fixer-allow-riskyne laisse aucun delta.- Aucun import croise entre modules ; tous les fichiers PHP crees vivent dans
Module/Core/outests/Module/Core/, tous les fichiers front dansfrontend/shared/. - Le spec est mergee avec le code (meme PR #3 empilee sur
feat/rbac-api) pour rester la reference du ticket.
17. Remarques de branche
- Branche de travail :
feat/rbac-voter, tiree defeat/rbac-api. - Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers
develop. - Une fois la PR #3 mergee, la branche finale de l'epic RBAC (
feat/rbac-admin-uipour #346) partira dedevelop.