# 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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`

### Tests fonctionnels PHP

- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
  Couvre l'enrichissement du payload `/api/me`.
- `/home/matthieu/dev_malio/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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/Starseed/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.
