From fd4ed25c63feda949d13f9835c59754b46451bf2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 15 Apr 2026 15:28:51 +0200 Subject: [PATCH] docs(core) : RBAC #345 - spec voter + usePermissions --- docs/rbac/ticket-345-spec.md | 574 +++++++++++++++++++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 docs/rbac/ticket-345-spec.md diff --git a/docs/rbac/ticket-345-spec.md b/docs/rbac/ticket-345-spec.md new file mode 100644 index 0000000..1816d10 --- /dev/null +++ b/docs/rbac/ticket-345-spec.md @@ -0,0 +1,574 @@ +# 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`.