Files
Coltura/docs/rbac/ticket-345-spec.md
THOLOT DECHENE Matthieu e8c2789435
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
RBAC - Système complet de permissions (Backend + Frontend) (#7)
## 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>
2026-04-17 12:34:38 +00:00

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.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<string> 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 :

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 :

  • GetCollectionis_granted('core.roles.view')
  • Getis_granted('core.roles.view')
  • Postis_granted('core.roles.manage')
  • Patchis_granted('core.roles.manage')
  • Deleteis_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')
  • GetCollectionis_granted('core.users.view')
  • Postis_granted('core.users.manage')
  • Patch (profil, sans name:) → is_granted('core.users.manage')
  • Patch (user_rbac_patch) → is_granted('core.users.manage')
  • Deleteis_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_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

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)

  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 :

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 :

  • 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

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 == 1LastAdminProtectionException.
  • 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 finalemake 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.