# 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.view` au catalogue `CoreModule::permissions()` et la synchroniser via `app:sync-permissions`. Documenter la regle par defaut "view + manage par ressource administrable" qui encadre les declarations futures. - Creer `PermissionVoter` Symfony qui : - supporte les attributs au format `module.resource[.sub].action` (regex explicite) sans interferer avec `ROLE_*`, - bypasse a `ACCESS_GRANTED` si `User::isAdmin() === true`, - sinon compare l'attribut a `User::getEffectivePermissions()`. - 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 entites `Permission`, `Role` et `User`. Supprimer les commentaires `// TODO ticket #345` en meme temps. - Creer un service domaine `AdminHeadcountGuard` dans `src/Module/Core/Domain/Security/` qui encapsule la regle "il doit toujours rester au moins un administrateur sur l'instance" et leve `LastAdminProtectionException` quand l'operation ferait tomber le compteur a zero. - Brancher le guard dans `UserRbacProcessor` (apres la garde auto-suicide existante) et dans un nouveau `UserProcessor` decorateur de `RemoveProcessor` qui intercepte `DELETE /api/users/{id}`. - Ajouter `UserRepositoryInterface::countAdmins(): int` et son implementation Doctrine. - Enrichir `/api/me` en exposant `effectivePermissions: list` via un `#[Groups(['me:read'])]` sur la methode existante `User::getEffectivePermissions()`. Aucun changement de `MeProvider`. - Livrer `frontend/shared/composables/usePermissions.ts` consommant `useAuthStore().user` (qui porte deja le payload `/api/me`). API publique : `can(code)`, `canAny(codes)`, `canAll(codes)`. - Etendre `frontend/shared/types/user-data.ts` avec les champs `isAdmin: boolean` et `effectivePermissions: string[]`. - Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension de `UserRbacProcessorTest`. - 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/me` avec `effectivePermissions`. - 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 `module` owner ; le filtrage par permission individuelle sera ajoute au #346 quand l'UI en aura besoin. - Audit log des mutations RBAC : traite par le futur `#355` audit log project, deliberement independant. - Decoupe fine de `core.users.manage` en 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_array` avec des collections deja `fetch=EAGER`, aucun cache necessaire. ## 3. Fichiers a creer ### Domaine - Securite - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.php` Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation. - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.php` Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors. ### Infrastructure - Security - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.php` Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`. ### Infrastructure - Processors - `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php` Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`. ### Frontend - Composable - `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.ts` Composable stateless qui lit `useAuthStore().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" sur `DELETE /api/users/{id}`. ### Tests frontend - `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.ts` Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). 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 : ```php 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 : ```php 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, sans `name:`) → `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` : ```php 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 : ```php #[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 : ```php /** * 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 : ```php 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 : ```text 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 : ```ts 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 ```php 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` ```text $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_FULLY` et `ROLE_USER` continue de fonctionner via `AuthenticatedVoter` et `RoleVoter` de Symfony, - un eventuel `is_granted('ROLE_ADMIN')` residuel dans le code continuerait de fonctionner via `RoleVoter` sans 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 ```php 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 : ```text 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) 1. `countAdmins() > 1` + demotion → OK (pas d'exception) 2. `countAdmins() == 1` + demotion → LEVE 3. `countAdmins() > 1` + deletion → OK 4. `countAdmins() == 1` + deletion → LEVE 5. `countAdmins() == 2` + demotion → OK (il en reste 1) 6. `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` : ```text 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 : ```json { "@context": "/api/contexts/User", "@id": "/api/users/5", "@type": "User", "id": 5, "username": "admin", "isAdmin": true } ``` Payload apres : ```json { "@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 : - `effectivePermissions` est toujours un tableau de strings (jamais `null`). - 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 `admin` a des permissions OU si l'user a des directPermissions, vide sinon). **Le bypass ne se refletera PAS dans ce tableau** : `isAdmin: true` reste 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/me` a 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 ```ts 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 `ref` module-level, aucune reactivite dediee. Tout passe par `useAuthStore().user` qui est deja reactif via Pinia. - **Aucun fetch propre** : les permissions arrivent par `/api/me` au login (via `useAuthStore().ensureSession()` ou `.login()`), aucun appel supplementaire n'est necessaire. - **Aucun reset** : le logout efface deja `authStore.user`, donc `can()` retombe naturellement a `false`. - **Bypass synchrone avec le back** : la regle `if (user.isAdmin) return true` duplique deliberement le bypass du `PermissionVoter` cote 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')` retourne `true`. - `supports('ROLE_ADMIN')` retourne `false` (n'interfere pas avec les voters core). - `supports('IS_AUTHENTICATED_FULLY')` retourne `false`. - `supports('invalid attribute')` retourne `false` (espace, majuscule). - `voteOnAttribute` avec un `User` admin retourne GRANTED quelle que soit la permission. - `voteOnAttribute` avec un user portant la permission retourne GRANTED. - `voteOnAttribute` avec un user ne portant pas la permission retourne DENIED. - `voteOnAttribute` avec 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 au `RemoveProcessor`. - `process()` sur un user admin en DELETE avec `countAdmins() > 1` delegue. - `process()` sur un user admin en DELETE avec `countAdmins() == 1` leve `BadRequestHttpException` (traduction de `LastAdminProtectionException`). - `process()` avec `$data` non-`User` leve `LogicException` (fail-fast coherent avec `UserRbacProcessor` / `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 juste `roles` ou `directPermissions`) ne consulte jamais le guard, meme si `countAdmins() == 1`. ### Fonctionnels API PHP (`AbstractApiTestCase`) Pour les 3 ressources (`Permission`, `Role`, `User`), pour chaque operation, 3 cas : 1. Admin → succes (confirme que le voter bypass fonctionne). 2. User standard **avec** la permission requise (attachee via fixture dediee) → succes. 3. 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}/rbac` avec `isAdmin: false` par un **autre** admin → `400` avec message "dernier admin" (et pas "auto-suicide"). - `UserApiTest` (nouveau ou extension) : DELETE `/api/users/{lastAdminId}` par un autre admin → `400` avec message "dernier admin". - `UserApiTest` : DELETE `/api/users/{nonAdminId}` fonctionne quel que soit le count (la garde ne doit pas etre appelee). - `MeApiTest` : `GET /api/me` en tant qu'admin retourne `effectivePermissions` (tableau, meme vide si pas de role populaire). - `MeApiTest` : `GET /api/me` en tant que user standard retourne `effectivePermissions` = list triee des codes issus de ses roles et directPermissions. ### Tests frontend (Vitest) **`usePermissions.test.ts`** - Utilisateur null → `can()` retourne `false` pour n'importe quel code. - Utilisateur admin → `can('core.users.view')` retourne `true` meme si `effectivePermissions` est vide. - Utilisateur non-admin avec `['core.users.view']` → `can('core.users.view')` = `true`, `can('core.users.manage')` = `false`. - `canAny(['a', 'b'])` retourne `true` si l'un des deux matche, `false` sinon. - `canAll(['a', 'b'])` retourne `true` uniquement 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/`, ou `frontend/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 `PermissionVoter` via `VoterInterface`. `AdminHeadcountGuard` est autowire via son constructeur standard. - Les processors suivent le pattern du #344 : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast sur type invalide. - Aucune entree necessaire dans `config/modules.php` ni `config/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) 1. **Catalogue** — ajouter `core.roles.view` dans `CoreModule::permissions()`. Executer `app:sync-permissions` en local pour verifier l'ajout. Pas de test propre (couvert indirectement par les tests sync existants du #343). 2. **Guard domaine** — creer `LastAdminProtectionException`, ajouter `UserRepositoryInterface::countAdmins()` + impl Doctrine, creer `AdminHeadcountGuard`. Ecrire `AdminHeadcountGuardTest`. 3. **PermissionVoter** — implementation + `PermissionVoterTest`. Verifier via `make test` que l'auth standard reste verte (aucune regression sur `ROLE_*`). 4. **UserProcessor DELETE** — creer le processor, wire sur l'operation `Delete` de `User`. Ecrire `UserProcessorTest`. 5. **UserRbacProcessor extension** — injecter `AdminHeadcountGuard`, brancher apres la garde auto-suicide. Etendre `UserRbacProcessorTest` avec les nouveaux cas. 6. **Remplacement des 13 gardes ROLE_ADMIN** — modifier `Permission`, `Role`, `User`. Supprimer tous les `// TODO ticket #345`. 7. **`/api/me` enrichi** — ajouter `#[Groups(['me:read'])]` sur `getEffectivePermissions()`. Creer ou etendre `MeApiTest`. 8. **Tests fonctionnels RBAC complets** — helper `createUserWithPermission()` dans `AbstractApiTestCase`, puis couverture 403 non-admin / 200 avec permission sur toutes les operations RBAC des 3 ressources. Cas "dernier admin global" PATCH et DELETE. 9. **Frontend types + composable** — etendre `UserData`, creer `usePermissions.ts`, ecrire le test Vitest. 10. **Verification finale** — `make test` vert, `make php-cs-fixer-allow-risky` sans 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** : `PermissionVoter` ne vote jamais sur `ROLE_*` grace au regex de support. Risque quasi-nul d'interference avec `RoleVoter`/`AuthenticatedVoter`, a valider par un test fonctionnel `/login_check` + `GET /api/me` apres 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 un `ReflectionException` si le nom de propriete deduit ne matche pas (cas rare, API Platform gere les getters normalement). Mitigation : test fonctionnel `/api/me` en premiere validation. - **Cout SQL de `countAdmins()`** : 1 `COUNT(*)` par operation de mutation admin sensible. Index recommande sur `user.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 table `user` d'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.ts` pointant 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 helper `createUserWithPermission()` transactionnelle dans la classe de test, plutot que polluer `AppFixtures` avec 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/me` plus gros** : l'ajout de `effectivePermissions` alourdit 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. - **`UserData` partagee entre auth store et composable** : toute modification future de la shape `UserData` peut impacter `usePermissions`. Rester minimal dans le composable et laisser Pinia porter la verite. ## 16. Criteres d'acceptation (DoD) - Le catalogue `CoreModule::permissions()` contient 5 entrees incluant `core.roles.view`. - `PermissionVoter` existe, supporte uniquement les attributs au format `module.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 par `ROLE_ADMIN`. Les commentaires `// TODO ticket #345` ont disparu du code. - `AdminHeadcountGuard` existe comme service domaine, est consomme par `UserRbacProcessor` ET `UserProcessor`, teste en isolation. - `UserRepositoryInterface::countAdmins()` existe et est implementee. - `UserProcessor` intercepte `DELETE /api/users/{id}` et bloque la suppression du dernier admin avec un message explicite. - `UserRbacProcessor` bloque la demotion du dernier admin global (en plus de la garde auto-suicide existante) avec un message distinct. - `GET /api/me` retourne `effectivePermissions: string[]` et `isAdmin: boolean` dans son payload. - `frontend/shared/composables/usePermissions.ts` expose `can`, `canAny`, `canAll`, stateless, bypasse si `isAdmin`. - `frontend/shared/types/user-data.ts` inclut `isAdmin` et `effectivePermissions`. - Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension `UserRbacProcessorTest` — 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/me` enrichi. - Test Vitest `usePermissions.test.ts` vert (ou TODO documentee si setup Vitest absent du projet). - `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta. - Aucun import croise entre modules ; tous les fichiers PHP crees vivent dans `Module/Core/` ou `tests/Module/Core/`, tous les fichiers front dans `frontend/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 de `feat/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-ui` pour #346) partira de `develop`. ## 18. Evolutions post-livraison — `UserRbacProcessor` defense in depth Voir aussi : `docs/sites/ticket-02-spec.md` § 10 pour la problematique cote Sites qui a motive cette evolution. ### 18.1 — Semantique `merge-patch+json` respectee Le processor originel appliquait telles quelles les mutations produites par la denormalisation API Platform. Or API Platform reinstancie par defaut une `ArrayCollection` vide pour chaque propriete ManyToMany absente du payload, ce qui viole la semantique `application/merge-patch+json` : les cles absentes ne doivent PAS muter les proprietes correspondantes. Consequence concrete du bug : un PATCH minimal comme `{ "isAdmin": true }` detruisait silencieusement toutes les collections (`rbacRoles`, `directPermissions`, `sites`) du user cible. La garde `restoreAbsentCollections()` introduite dans `UserRbacProcessor` resout cela en : 1. Injectant `RequestStack` pour lire le body JSON brut de la requete. 2. Decodant les cles effectivement envoyees par le client. 3. Pour chaque cle RBAC (`roles`, `directPermissions`, `sites`) absente du payload : restaurant la collection a son etat d'origine a partir du snapshot Doctrine (`PersistentCollection::getSnapshot()`), puis appelant `takeSnapshot()` pour marquer la collection comme non-dirty (aucune query `UPDATE` n'est emise sur les tables de jointure). 4. No-op si la cle est presente (la denormalisation fait foi). Matrice finale : | Payload | Effet | |---------------------------------|-------------------------------------| | Cle absente | Propriete preservee (BDD inchangee) | | Cle presente = `[]` | Collection videe (vidage explicite) | | Cle presente = `[...]` | Collection remplacee | ### 18.2 — Nouvelle operation `GET /users/{id}/rbac` Le drawer d'edition (`UserRbacDrawer.vue`) ne peut plus dependre du payload de liste `/api/users` (groupe `user:list`) pour initialiser l'etat `sites` car ce groupe reste volontairement leger (cf. ticket Sites #02). Une operation `Get` dediee est ajoutee, symetrique au `Patch` existant : - URI : `/users/{id}/rbac` - Security : `is_granted('core.users.manage')` (plus strict que `.view`) - Groupe : `user:rbac:read` (contient `isAdmin`, `roles`, `directPermissions`, `sites`). Le drawer charge desormais ce GET en parallele des referentiels au moment de l'ouverture, via un watch combine `[modelValue, user.id]` qui recharge correctement si le user change sans fermeture du drawer entre-temps. ### 18.3 — Impact sur les tests `UserRbacProcessorTest` : le constructor gagne un argument `RequestStack`. Les tests existants injectent une `RequestStack` avec une `Request` vide (body `""`), ce qui rend la garde no-op — le comportement des assertions existantes est conserve. De nouveaux tests couvrent la garde : - PATCH sans cle `sites` ne mute pas la collection d'origine. - PATCH avec `sites: []` vide bien la collection (pas de regression du cas "vidage explicite"). - PATCH avec `sites: [...]` remplace comme avant. ### 18.4 — Criteres de validation additionnels - [ ] `GET /users/{id}/rbac` retourne 200 avec `core.users.manage`, 403 sans. - [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`. - [ ] `PATCH /users/{id}/rbac` avec cle absente preserve la collection BDD. - [ ] `PATCH /users/{id}/rbac` avec `[]` vide la collection et declenche `ensureCurrentSiteConsistency` (cas sites). - [ ] Les 228 tests PHPUnit existants passent apres ajout du parametre `RequestStack` au constructor.