## Résumé
Implémente le journal d'audit append-only couvrant les 5 tickets de `doc/audit-log.md` et embarque au passage plusieurs corrections périphériques (sidebar Admin/Mon compte, drawer RBAC, Swagger, schema_filter Doctrine) ainsi que l'initialisation de la suite e2e Playwright. Toutes les mutations Doctrine sur les entités portant `#[Auditable]` sont tracées dans une table PostgreSQL dédiée, exposée en lecture seule via API Platform et consultable par les admins dans une page dédiée.
## Ce qui change
### Audit log — cœur de la PR
**Backend**
- Migration : table `audit_log` (UUID v7 natif Postgres en PK, `jsonb changes`, 3 index pour tri chrono, par entité et par utilisateur).
- `AuditLogWriter` : service bas-niveau, écrit via une connexion DBAL dédiée `audit` (même DSN que `default`, service séparé) pour sortir de la transaction ORM en batch. Blacklist defense-in-depth `password`/`plainPassword`/`token`/`secret`.
- `RequestIdProvider` : UUID v4 généré au `kernel.request` principal, injecté dans chaque ligne d'audit de la requête.
- Attributs `#[Auditable]` / `#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` (accessibles par tous les modules).
- `AuditListener` : capture `onFlush` / écriture `postFlush` avec pattern swap-and-clear contre les flushes ré-entrants. Erreurs loguées, jamais propagées. Entité `User` annotée (password / plainPassword ignorés).
- API Platform read-only `/api/audit-logs` (permission RBAC `core.audit_log.view`) : `GET` collection paginée + `GET` item, pas de POST/PUT/PATCH/DELETE. Filtres `entity_type`, `entity_id`, `action`, `performed_by`, `performed_at[after]`/`[before]`.
- `DbalPaginator` implémentant `PaginatorInterface` : `hydra:view` généré automatiquement par API Platform, pas de construction manuelle.
- Ressource `AuditLogEntityTypesResource` + provider dédié pour peupler le filtre par type d'entité côté UI (réponse cachée, pas de requête à chaque ouverture du drawer).
- Permission `core.audit_log.view` déclarée dans `CoreModule::permissions()`.
- `audit_log` exclu du `schema_filter` Doctrine : plus de faux diff sur `make migration-diff`.
**Frontend**
- Page admin `/admin/audit-log` : tableau paginé, filtres locaux (état dans le composant, non persistés dans l'URL — conforme règle CLAUDE.md « Tableaux : pas de persistance URL »), drawer de détail (diff + timeline complète de l'entité), badges colorés par action.
- Composable partagé `useAuditLog` avec `resetAuditLog()` auto-enregistré sur `onAuthSessionCleared` (règle CLAUDE.md composables singletons).
- Composant réutilisable `<AuditTimeline :entity-type :entity-id>` : garde permission (pas d'appel API sans le droit), lazy loading (10 items + bouton « Voir plus »), dates relatives FR via `Intl.RelativeTimeFormat`, skeleton loader.
- Entrée sidebar « Journal d'audit » gated sur `core.audit_log.view` + clés i18n imbriquées dans `fr.json`.
### Fixes embarqués
- **Review fixes audit-log** (commits `37eafd2`, `1505e84`, `99c77eb`) : précision des timestamps, `ESCAPE` sur les `LIKE`, plafond pagination, diverses remarques du 1er tour de review.
- **Sidebar** (`701a480`, `e2fbf51`) : nouvelle section « Administration » + groupe « Mon compte », gate de section sur permissions, « Tableau de bord » déplacé dans « Mon compte ». Convention admin documentée.
- **Drawer RBAC utilisateurs** (`617ee31`, `5f5afcc`) : corrige l'affichage des sites et l'écrasement via merge-patch (garde anti-écrasement + spec `GET /users/{id}/rbac` documentée).
- **Swagger UI** (`6db955f`) : réactivé en ajoutant `symfony/twig-bundle` aux deps (régression depuis l'arrivée d'API Platform 4.2).
- **`phpunit.dist.xml`** : `<env APP_ENV=dev>` forçait la suite à tourner sous `framework.test=false` (→ `test.service_container` introuvable) ; `JWT_PASSPHRASE` ne matchait pas les clés de dev. Corrigés pour débloquer la suite.
### E2E Playwright (nouveau, commit `4603ab2`)
- `playwright.config.ts` + structure `frontend/tests/e2e/` (personas, helpers `loginAs`, page objects `LoginPage` + `SidebarComponent`).
- Specs : `auth/login.spec.ts` + `permissions/sidebar-visibility.spec.ts` (vérifie la visibilité de la sidebar par rôle RBAC).
- Commande `SeedE2ECommand` pour préparer un jeu de données déterministe côté backend.
- `make e2e` ajouté au Makefile.
## Décisions techniques
- **UUID v7 natif Postgres** (16 octets vs 36 en varchar) : index `performed_at` ~40 % plus petit sur une table append-only à croissance infinie.
- **`entity_type` format `module.Entity`** (ex: `core.User`) : évite les collisions si deux modules ont des entités de même nom.
- **`performed_by` dénormalisé** (string, pas FK) : le nom persiste même après suppression de l'utilisateur.
- **Connexion DBAL dédiée `audit`** : évite l'entanglement transactionnel entre audit et ORM en batch.
- **`ManyToMany` non audité** : limitation connue (`getEntityChangeSet()` ne couvre pas les collections) ; extension future via `getScheduledCollectionUpdates()` si besoin.
- **Filtres locaux non persistés dans l'URL** : choix assumé (cf. CLAUDE.md) pour éviter le couplage table ↔ routeur.
## Test plan
- [x] `make test` : 218 tests passent (writer unitaires + listener intégration + API fonctionnels + UserRbacProcessor).
- [x] `npm run lint` + `npm run test` + `npm run build` (frontend).
- [x] Migration appliquée sur dev + test, `audit_log` ignoré par `schema_filter`.
- [x] Permissions synchronisées (`app:sync-permissions`).
- [x] Swagger `/api/docs` accessible de nouveau.
- [ ] Playwright : `make e2e` vert en local (login + sidebar-visibility).
- [ ] Vérifier en local : création/modif/suppression d'un user apparaît dans `/admin/audit-log`.
- [ ] Vérifier : user sans `core.audit_log.view` → 403 sur l'endpoint + item absent de la sidebar.
- [ ] Vérifier : expansion d'une ligne affiche la timeline de l'entité avec dates relatives FR.
- [ ] Vérifier : drawer RBAC utilisateur n'écrase plus la liste des sites au `PATCH`.
## Points d'attention pour le review
- `AuditListener` : pattern swap-and-clear sur `postFlush` — relire la gestion des flushes ré-entrants.
- `DbalPaginator` : vérifier que l'absence d'`Iterator` custom ne casse pas la normalisation API Platform sur collections vides.
- `UserRbacProcessor` : logique merge-patch + garde anti-écrasement des sites (régression corrigée dans `617ee31`).
- Playwright : nouvelle dépendance de dev, s'assurer que `make e2e` ne fait pas partie du pipeline CI par défaut (à brancher explicitement).
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #9
Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-committed-by: matthieu <matthieu@yuno.malio.fr>
650 lines
36 KiB
Markdown
650 lines
36 KiB
Markdown
# 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 :
|
|
|
|
```php
|
|
public static function permissions(): array
|
|
{
|
|
return [
|
|
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
|
|
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
|
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
|
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
|
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
|
|
];
|
|
}
|
|
```
|
|
|
|
La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la prochaine execution, sans migration Doctrine necessaire (le catalogue est propriete exclusive de la commande de sync depuis le #343).
|
|
|
|
### Entite `Permission`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
|
|
|
Remplacer les 2 gardes placeholder :
|
|
|
|
```php
|
|
new GetCollection(
|
|
normalizationContext: ['groups' => ['permission:read']],
|
|
security: "is_granted('core.permissions.view')",
|
|
),
|
|
new Get(
|
|
normalizationContext: ['groups' => ['permission:read']],
|
|
security: "is_granted('core.permissions.view')",
|
|
),
|
|
```
|
|
|
|
Supprimer les commentaires `// TODO ticket #345`.
|
|
|
|
### Entite `Role`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
|
|
|
Remplacer les 5 gardes placeholder :
|
|
|
|
- `GetCollection` → `is_granted('core.roles.view')`
|
|
- `Get` → `is_granted('core.roles.view')`
|
|
- `Post` → `is_granted('core.roles.manage')`
|
|
- `Patch` → `is_granted('core.roles.manage')`
|
|
- `Delete` → `is_granted('core.roles.manage')`
|
|
|
|
Supprimer les commentaires `// TODO ticket #345`.
|
|
|
|
### Entite `User`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
|
|
|
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
|
|
|
- `Get` (item) → `is_granted('core.users.view')`
|
|
- `GetCollection` → `is_granted('core.users.view')`
|
|
- `Post` → `is_granted('core.users.manage')`
|
|
- `Patch` (profil, sans `name:`) → `is_granted('core.users.manage')`
|
|
- `Patch` (`user_rbac_patch`) → `is_granted('core.users.manage')`
|
|
- `Delete` → `is_granted('core.users.manage')`
|
|
|
|
Note : l'operation `Get /me` n'a aucune garde (seulement `IS_AUTHENTICATED_FULLY` implicite via `security.yaml`). Ce n'est pas une operation RBAC, elle reste inchangee.
|
|
|
|
Ajouter le processor `UserProcessor::class` sur l'operation `Delete` :
|
|
|
|
```php
|
|
new Delete(
|
|
security: "is_granted('core.users.manage')",
|
|
processor: UserProcessor::class,
|
|
),
|
|
```
|
|
|
|
Exposer `getEffectivePermissions()` dans le groupe `me:read` — ajouter l'attribut sur la methode existante :
|
|
|
|
```php
|
|
#[Groups(['me:read'])]
|
|
public function getEffectivePermissions(): array
|
|
{
|
|
// implementation existante, inchangee
|
|
}
|
|
```
|
|
|
|
Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
|
|
|
### `UserRepositoryInterface`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
|
|
|
Ajouter la methode :
|
|
|
|
```php
|
|
/**
|
|
* Compte le nombre d'utilisateurs avec le flag isAdmin = true.
|
|
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
|
* "au moins un administrateur reste sur l'instance".
|
|
*/
|
|
public function countAdmins(): int;
|
|
```
|
|
|
|
### `DoctrineUserRepository`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
|
|
|
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
|
|
|
```php
|
|
public function countAdmins(): int
|
|
{
|
|
return (int) $this->createQueryBuilder('u')
|
|
->select('COUNT(u.id)')
|
|
->where('u.isAdmin = true')
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
```
|
|
|
|
### `UserRbacProcessor`
|
|
|
|
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
|
|
|
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
|
|
|
Logique :
|
|
|
|
```text
|
|
1. Garde auto-suicide existante (inchangee).
|
|
2. Si l'operation entraine la perte du flag isAdmin (wasAdmin && !data.isAdmin):
|
|
AdminHeadcountGuard::ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
|
3. Delegation au persist processor.
|
|
```
|
|
|
|
La detection "wasAdmin && !data.isAdmin" reutilise le meme `UnitOfWork::getOriginalEntityData()` deja utilise par la garde auto-suicide.
|
|
|
|
### `frontend/shared/types/user-data.ts`
|
|
|
|
Ajouter les champs :
|
|
|
|
```ts
|
|
export interface UserData {
|
|
id: number
|
|
username: string
|
|
isAdmin: boolean
|
|
effectivePermissions: string[]
|
|
// ... champs existants
|
|
}
|
|
```
|
|
|
|
### `frontend/shared/services/auth.ts`
|
|
|
|
A verifier : si `getCurrentUser()` type deja le retour sur `UserData`, rien a changer — les nouveaux champs arrivent automatiquement car l'API les renvoie. Si un mapping manuel est fait dans le service, l'etendre pour ne pas perdre `isAdmin` et `effectivePermissions`. A valider au debut de la task frontend.
|
|
|
|
## 5. PermissionVoter - details d'implementation
|
|
|
|
### Regex de support
|
|
|
|
```php
|
|
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
|
```
|
|
|
|
Garantit :
|
|
- premier caractere alphabetique minuscule,
|
|
- au moins un point de separation (ecarte les `ROLE_*`),
|
|
- segments en snake_case minuscules coherents avec les permissions declarees par les modules.
|
|
|
|
### `supports(string $attribute, mixed $subject): bool`
|
|
|
|
Retourne `(bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute)`. Le `$subject` est ignore : les permissions sont portees par l'utilisateur, pas par une ressource ciblee. Pour l'instant l'autorisation est uniquement basee sur l'identite de l'acteur — les scopes ressource (ex. "edit this specific role") seront traites par un voter dedie si un module metier en a besoin.
|
|
|
|
### `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool`
|
|
|
|
```text
|
|
$user = $token->getUser()
|
|
if (!$user instanceof User) return false // ACCESS_DENIED
|
|
if ($user->isAdmin()) return true // bypass total
|
|
return in_array($attribute, $user->getEffectivePermissions(), true)
|
|
```
|
|
|
|
### Interaction avec les autres voters
|
|
|
|
Strategie par defaut Symfony `affirmative` : des qu'un voter renvoie GRANTED, l'acces est accorde. `PermissionVoter` ne vote **jamais** sur les attributs `ROLE_*` (filtres par `supports()`), donc :
|
|
|
|
- l'authentification classique `IS_AUTHENTICATED_FULLY` et `ROLE_USER` continue de fonctionner via `AuthenticatedVoter` et `RoleVoter` de Symfony,
|
|
- un eventuel `is_granted('ROLE_ADMIN')` residuel dans le code continuerait de fonctionner via `RoleVoter` sans interference.
|
|
|
|
Un test fonctionnel `make test` complet verifiera que l'auth standard marche toujours apres ajout du voter.
|
|
|
|
### Wiring
|
|
|
|
`autoconfigure: true` dans `services.yaml` (deja active) detecte la classe via l'interface `VoterInterface`. **Aucun** wiring manuel necessaire dans `services.yaml`.
|
|
|
|
## 6. AdminHeadcountGuard - regles metier
|
|
|
|
### Invariant global
|
|
|
|
> Apres toute operation terminee avec succes, `countAdmins() >= 1`.
|
|
|
|
### API publique
|
|
|
|
```php
|
|
final class AdminHeadcountGuard
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepositoryInterface $userRepository,
|
|
) {}
|
|
|
|
/**
|
|
* Leve si retirer le flag isAdmin a $user ferait tomber le total a zero.
|
|
* A appeler UNIQUEMENT dans la branche "l'operation retire effectivement isAdmin".
|
|
*/
|
|
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
|
|
|
/**
|
|
* Leve si supprimer physiquement $user ferait tomber le total a zero.
|
|
* A appeler UNIQUEMENT dans la branche DELETE sur un user admin.
|
|
*/
|
|
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
|
}
|
|
```
|
|
|
|
Deux methodes semantiques distinctes plutot qu'une methode generique avec un parametre booleen : ca rend les call-sites lisibles et les tests auto-documentes.
|
|
|
|
### Logique
|
|
|
|
Pour les deux methodes, la regle effective est identique :
|
|
|
|
```text
|
|
if ($this->userRepository->countAdmins() <= 1) {
|
|
throw new LastAdminProtectionException(
|
|
'Impossible : au moins un administrateur doit rester sur l\'instance.'
|
|
);
|
|
}
|
|
```
|
|
|
|
Les appelants ne passent le guard que si l'operation retire reellement un admin — le guard n'a donc pas a raisonner sur l'etat entrant. Cette separation des responsabilites (le processor decide "est-ce qu'on perd un admin ?", le guard applique "si oui, compte") garde les deux composants minimalistes et testables independamment.
|
|
|
|
### Cas couverts (tests)
|
|
|
|
1. `countAdmins() > 1` + demotion → OK (pas d'exception)
|
|
2. `countAdmins() == 1` + demotion → LEVE
|
|
3. `countAdmins() > 1` + deletion → OK
|
|
4. `countAdmins() == 1` + deletion → LEVE
|
|
5. `countAdmins() == 2` + demotion → OK (il en reste 1)
|
|
6. `countAdmins() == 0` + demotion → LEVE (cas theorique, garde defensive)
|
|
|
|
## 7. Garde "dernier admin" - cohabitation avec l'auto-suicide
|
|
|
|
Les deux gardes sont distinctes et non fusionnables :
|
|
|
|
- **Auto-suicide (existante, #344)** : "un admin ne peut pas retirer ses PROPRES droits admin". S'applique meme s'il existe d'autres admins. Protege contre le recovery penible d'un admin qui se cliquerait degrade tout seul.
|
|
- **Dernier admin global (nouveau, #345)** : "l'instance doit toujours avoir au moins un admin". S'applique meme si ce n'est pas l'operation d'un admin sur lui-meme (admin A degrade admin B alors qu'ils sont les deux seuls).
|
|
|
|
Ordre d'evaluation dans `UserRbacProcessor` :
|
|
|
|
```text
|
|
1. Garde auto-suicide (cas particulier, message dedie)
|
|
2. Garde dernier admin global (cas general, message dedie)
|
|
3. Persist
|
|
```
|
|
|
|
Les messages d'erreur distincts aident le front a afficher le bon feedback utilisateur. Le test `UserRbacProcessorTest` doit couvrir les deux branches.
|
|
|
|
### Cas limite : l'admin se degrade lui-meme ET il est le dernier
|
|
|
|
Les deux gardes s'appliqueraient. Comme auto-suicide est evalue en premier, c'est son message qui est retourne ("Vous ne pouvez pas retirer vos propres droits administrateur."). Comportement acceptable et coherent : le user voit d'abord la regle la plus specifique.
|
|
|
|
## 8. /api/me enrichi - contrat
|
|
|
|
Payload avant :
|
|
```json
|
|
{
|
|
"@context": "/api/contexts/User",
|
|
"@id": "/api/users/5",
|
|
"@type": "User",
|
|
"id": 5,
|
|
"username": "admin",
|
|
"isAdmin": true
|
|
}
|
|
```
|
|
|
|
Payload apres :
|
|
```json
|
|
{
|
|
"@context": "/api/contexts/User",
|
|
"@id": "/api/users/5",
|
|
"@type": "User",
|
|
"id": 5,
|
|
"username": "admin",
|
|
"isAdmin": true,
|
|
"effectivePermissions": [
|
|
"core.permissions.view",
|
|
"core.roles.manage",
|
|
"core.roles.view",
|
|
"core.users.manage",
|
|
"core.users.view"
|
|
]
|
|
}
|
|
```
|
|
|
|
Contrat :
|
|
- `effectivePermissions` est toujours un tableau de strings (jamais `null`).
|
|
- L'ordre est deterministe (trie alphabetique — implementation existante du #343).
|
|
- Aucun doublon.
|
|
- Pour un admin, le tableau contient les permissions effectives (non vides si le role `admin` a des permissions OU si l'user a des directPermissions, vide sinon). **Le bypass ne se refletera PAS dans ce tableau** : `isAdmin: true` reste la source de verite du bypass. Le front l'utilise en priorite dans le composable.
|
|
|
|
### Pourquoi le bypass n'est pas materialise dans `effectivePermissions`
|
|
|
|
Mettre "toutes les permissions connues" dans le tableau pour les admins serait tentant mais faux :
|
|
- il faudrait enumerer dynamiquement toutes les permissions de tous les modules actifs, ce qui recouvre la responsabilite de `app:sync-permissions`,
|
|
- le tableau gonflerait inutilement le payload `/api/me` a chaque requete,
|
|
- et surtout il deviendrait faux si un module declare une nouvelle permission apres une execution de sync : l'admin aurait temporairement un tableau incomplet alors que son bypass reste effectif.
|
|
|
|
La source de verite du bypass est `isAdmin: boolean`. Le composable front regarde ce flag en premier.
|
|
|
|
## 9. usePermissions - composable front
|
|
|
|
### API publique
|
|
|
|
```ts
|
|
export function usePermissions() {
|
|
const auth = useAuthStore()
|
|
|
|
// Verifie si l'utilisateur courant a la permission demandee.
|
|
// Bypass automatique si isAdmin = true, coherent avec PermissionVoter cote back.
|
|
const can = (code: string): boolean => {
|
|
const user = auth.user
|
|
if (!user) return false
|
|
if (user.isAdmin) return true
|
|
return user.effectivePermissions.includes(code)
|
|
}
|
|
|
|
const canAny = (codes: string[]): boolean => codes.some(can)
|
|
const canAll = (codes: string[]): boolean => codes.every(can)
|
|
|
|
return { can, canAny, canAll }
|
|
}
|
|
```
|
|
|
|
### Proprietes
|
|
|
|
- **Stateless** : aucun `ref` module-level, aucune reactivite dediee. Tout passe par `useAuthStore().user` qui est deja reactif via Pinia.
|
|
- **Aucun fetch propre** : les permissions arrivent par `/api/me` au login (via `useAuthStore().ensureSession()` ou `.login()`), aucun appel supplementaire n'est necessaire.
|
|
- **Aucun reset** : le logout efface deja `authStore.user`, donc `can()` retombe naturellement a `false`.
|
|
- **Bypass synchrone avec le back** : la regle `if (user.isAdmin) return true` duplique deliberement le bypass du `PermissionVoter` cote back. Commentaire francais dans le composable pour rappeler que les deux doivent bouger ensemble si la regle change un jour.
|
|
|
|
### Pas de variante `can` reactive (computed)
|
|
|
|
Utiliser `computed(() => can('core.users.view'))` dans un composant fonctionne automatiquement puisque `auth.user` est reactif Pinia — Vue re-evalue le computed quand `user` change. Pas besoin d'API supplementaire du composable pour ca.
|
|
|
|
## 10. Validation
|
|
|
|
Aucune nouvelle contrainte Symfony Validator introduite par ce ticket. Les gardes metier (`AdminHeadcountGuard`, `SystemRoleDeletionException`, auto-suicide) vivent dans les processors et le domaine, pas dans la couche Validator.
|
|
|
|
## 11. Plan de tests
|
|
|
|
### Unitaires PHP
|
|
|
|
**`PermissionVoterTest`**
|
|
|
|
- `supports('core.users.view')` retourne `true`.
|
|
- `supports('ROLE_ADMIN')` retourne `false` (n'interfere pas avec les voters core).
|
|
- `supports('IS_AUTHENTICATED_FULLY')` retourne `false`.
|
|
- `supports('invalid attribute')` retourne `false` (espace, majuscule).
|
|
- `voteOnAttribute` avec un `User` admin retourne GRANTED quelle que soit la permission.
|
|
- `voteOnAttribute` avec un user portant la permission retourne GRANTED.
|
|
- `voteOnAttribute` avec un user ne portant pas la permission retourne DENIED.
|
|
- `voteOnAttribute` avec un token non-authentifie (user null) retourne DENIED.
|
|
|
|
**`AdminHeadcountGuardTest`**
|
|
|
|
- `ensureAtLeastOneAdminRemainsAfterDemotion` : `countAdmins == 2` → OK.
|
|
- Meme methode : `countAdmins == 1` → `LastAdminProtectionException`.
|
|
- Meme methode : `countAdmins == 0` → leve aussi (garde defensive).
|
|
- `ensureAtLeastOneAdminRemainsAfterDeletion` : memes 3 cas, memes resultats.
|
|
- `UserRepositoryInterface::countAdmins()` est mockee avec une valeur fixe pour chaque cas (test unitaire isole, pas d'acces BDD).
|
|
|
|
**`UserProcessorTest`**
|
|
|
|
- `process()` sur un user non-admin en DELETE delegue au `RemoveProcessor`.
|
|
- `process()` sur un user admin en DELETE avec `countAdmins() > 1` delegue.
|
|
- `process()` sur un user admin en DELETE avec `countAdmins() == 1` leve `BadRequestHttpException` (traduction de `LastAdminProtectionException`).
|
|
- `process()` avec `$data` non-`User` leve `LogicException` (fail-fast coherent avec `UserRbacProcessor` / `RoleProcessor`).
|
|
|
|
**`UserRbacProcessorTest` (extension)**
|
|
|
|
- Cas existants auto-suicide : gardes en l'etat.
|
|
- Nouveau : PATCH RBAC par admin A sur admin B, `isAdmin: false`, `countAdmins() == 1` (apres perte = 0) → `BadRequestHttpException` "dernier admin".
|
|
- Nouveau : meme operation avec `countAdmins() == 2` → delegue au persist processor.
|
|
- Nouveau : PATCH RBAC qui ne touche pas `isAdmin` (change juste `roles` ou `directPermissions`) ne consulte jamais le guard, meme si `countAdmins() == 1`.
|
|
|
|
### Fonctionnels API PHP (`AbstractApiTestCase`)
|
|
|
|
Pour les 3 ressources (`Permission`, `Role`, `User`), pour chaque operation, 3 cas :
|
|
|
|
1. Admin → succes (confirme que le voter bypass fonctionne).
|
|
2. User standard **avec** la permission requise (attachee via fixture dediee) → succes.
|
|
3. User standard **sans** la permission → `403`.
|
|
|
|
**Fixtures de test** : ajouter des users "portant une permission specifique" n'est pas souhaitable dans `AppFixtures` (fixtures de dev). Creer a la place un trait ou une helper method `AbstractApiTestCase::createUserWithPermission(string $code): User` qui instancie a la volee un user + un role + l'attache dans le test lui-meme, transactionne si `DAMADoctrineTestBundle` est en place.
|
|
|
|
**Cas specifiques a ajouter** :
|
|
|
|
- `UserRbacApiTest` : PATCH `/api/users/{lastAdminId}/rbac` avec `isAdmin: false` par un **autre** admin → `400` avec message "dernier admin" (et pas "auto-suicide").
|
|
- `UserApiTest` (nouveau ou extension) : DELETE `/api/users/{lastAdminId}` par un autre admin → `400` avec message "dernier admin".
|
|
- `UserApiTest` : DELETE `/api/users/{nonAdminId}` fonctionne quel que soit le count (la garde ne doit pas etre appelee).
|
|
- `MeApiTest` : `GET /api/me` en tant qu'admin retourne `effectivePermissions` (tableau, meme vide si pas de role populaire).
|
|
- `MeApiTest` : `GET /api/me` en tant que user standard retourne `effectivePermissions` = list triee des codes issus de ses roles et directPermissions.
|
|
|
|
### Tests frontend (Vitest)
|
|
|
|
**`usePermissions.test.ts`**
|
|
|
|
- Utilisateur null → `can()` retourne `false` pour n'importe quel code.
|
|
- Utilisateur admin → `can('core.users.view')` retourne `true` meme si `effectivePermissions` est vide.
|
|
- Utilisateur non-admin avec `['core.users.view']` → `can('core.users.view')` = `true`, `can('core.users.manage')` = `false`.
|
|
- `canAny(['a', 'b'])` retourne `true` si l'un des deux matche, `false` sinon.
|
|
- `canAll(['a', 'b'])` retourne `true` uniquement si les deux matchent.
|
|
|
|
Convention de test frontend a valider avant : si le projet Nuxt a deja un setup Vitest, on s'y aligne ; sinon on note une TODO pour ajouter la conf (sans bloquer le ticket — le composable est assez simple pour etre revu manuellement).
|
|
|
|
## 12. Securite et traduction d'exceptions
|
|
|
|
- `LastAdminProtectionException` (domaine) → `BadRequestHttpException` (400) dans les processors. Message francais : "Impossible : au moins un administrateur doit rester sur l'instance."
|
|
- `SystemRoleDeletionException` (existante) → traduction inchangee par le #344, rien a modifier.
|
|
- Auto-suicide existante → message inchange : "Vous ne pouvez pas retirer vos propres droits administrateur."
|
|
- Pas de listener global : traduction locale dans chaque processor, coherent avec le pattern du #344.
|
|
|
|
## 13. Conventions et architecture
|
|
|
|
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/`, `tests/Module/Core/`, ou `frontend/shared/`. Aucun import inter-modules.
|
|
- `declare(strict_types=1)` en tete de tous les nouveaux fichiers PHP.
|
|
- Commentaires PHP et TS en francais, identifiants en anglais (`CLAUDE.md`).
|
|
- Autoconfigure Symfony detecte `PermissionVoter` via `VoterInterface`. `AdminHeadcountGuard` est autowire via son constructeur standard.
|
|
- Les processors suivent le pattern du #344 : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast sur type invalide.
|
|
- Aucune entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
|
|
- Aucune migration Doctrine : le catalogue de permissions est synchronise par `app:sync-permissions` (commande existante #343), pas par une migration.
|
|
|
|
## 14. Ordre d'execution recommande (subagent-driven)
|
|
|
|
1. **Catalogue** — ajouter `core.roles.view` dans `CoreModule::permissions()`. Executer `app:sync-permissions` en local pour verifier l'ajout. Pas de test propre (couvert indirectement par les tests sync existants du #343).
|
|
2. **Guard domaine** — creer `LastAdminProtectionException`, ajouter `UserRepositoryInterface::countAdmins()` + impl Doctrine, creer `AdminHeadcountGuard`. Ecrire `AdminHeadcountGuardTest`.
|
|
3. **PermissionVoter** — implementation + `PermissionVoterTest`. Verifier via `make test` que l'auth standard reste verte (aucune regression sur `ROLE_*`).
|
|
4. **UserProcessor DELETE** — creer le processor, wire sur l'operation `Delete` de `User`. Ecrire `UserProcessorTest`.
|
|
5. **UserRbacProcessor extension** — injecter `AdminHeadcountGuard`, brancher apres la garde auto-suicide. Etendre `UserRbacProcessorTest` avec les nouveaux cas.
|
|
6. **Remplacement des 13 gardes ROLE_ADMIN** — modifier `Permission`, `Role`, `User`. Supprimer tous les `// TODO ticket #345`.
|
|
7. **`/api/me` enrichi** — ajouter `#[Groups(['me:read'])]` sur `getEffectivePermissions()`. Creer ou etendre `MeApiTest`.
|
|
8. **Tests fonctionnels RBAC complets** — helper `createUserWithPermission()` dans `AbstractApiTestCase`, puis couverture 403 non-admin / 200 avec permission sur toutes les operations RBAC des 3 ressources. Cas "dernier admin global" PATCH et DELETE.
|
|
9. **Frontend types + composable** — etendre `UserData`, creer `usePermissions.ts`, ecrire le test Vitest.
|
|
10. **Verification finale** — `make test` vert, `make php-cs-fixer-allow-risky` sans delta, build Nuxt OK si modifie.
|
|
|
|
Chaque etape doit etre revue (spec compliance + code quality) avant de passer a la suivante, pattern subagent-driven-development retenu pour le #344.
|
|
|
|
## 15. Risques et points d'attention
|
|
|
|
- **Ordre des voters Symfony** : `PermissionVoter` ne vote jamais sur `ROLE_*` grace au regex de support. Risque quasi-nul d'interference avec `RoleVoter`/`AuthenticatedVoter`, a valider par un test fonctionnel `/login_check` + `GET /api/me` apres ajout du voter.
|
|
- **Serialisation de `getEffectivePermissions()` via API Platform** : la methode existe depuis le #343 mais n'a jamais ete sous serializer. Risque de rencontrer un `ReflectionException` si le nom de propriete deduit ne matche pas (cas rare, API Platform gere les getters normalement). Mitigation : test fonctionnel `/api/me` en premiere validation.
|
|
- **Cout SQL de `countAdmins()`** : 1 `COUNT(*)` par operation de mutation admin sensible. Index recommande sur `user.is_admin` (`idx_user_is_admin`) — a verifier si la migration #343 l'a deja cree. Si non, c'est un ajustement cosmetique qu'on peut reporter puisque la table `user` d'un CRM PME reste petite (< 1000 lignes).
|
|
- **Bypass front/back desynchronise** : si un jour le bypass admin est affine cote back (ex: seulement sur certains modules), le composable front doit bouger en meme temps. Mitigation : commentaire francais explicite dans `usePermissions.ts` pointant vers cette spec.
|
|
- **Tests fonctionnels et fixtures RBAC** : le #344 a introduit `AbstractApiTestCase`, mais les users de test portant une permission specifique (hors admin/user standard) n'existent pas dans les fixtures. Creer une helper `createUserWithPermission()` transactionnelle dans la classe de test, plutot que polluer `AppFixtures` avec des users de test dedies.
|
|
- **Ordre d'evaluation auto-suicide vs dernier admin** : les deux gardes pourraient etre declenchees simultanement (admin unique qui se degrade lui-meme). L'auto-suicide gagne en premier par design. A couvrir explicitement par un test.
|
|
- **Payload `/api/me` plus gros** : l'ajout de `effectivePermissions` alourdit chaque requete `/api/me`. Pour 5 permissions aujourd'hui c'est negligeable, mais si le catalogue grossit fortement (50+ permissions reparties sur plusieurs modules), il faudra peut-etre filtrer cote serveur (ne retourner que les permissions utiles au contexte front). Hors scope, mais a noter pour suivi.
|
|
- **`UserData` partagee entre auth store et composable** : toute modification future de la shape `UserData` peut impacter `usePermissions`. Rester minimal dans le composable et laisser Pinia porter la verite.
|
|
|
|
## 16. Criteres d'acceptation (DoD)
|
|
|
|
- Le catalogue `CoreModule::permissions()` contient 5 entrees incluant `core.roles.view`.
|
|
- `PermissionVoter` existe, supporte uniquement les attributs au format `module.resource.action`, bypass admin effectif, test unitaire complet.
|
|
- Les 13 operations API Platform du perimetre RBAC sont toutes gardees par un code metier `core.*.*` et plus par `ROLE_ADMIN`. Les commentaires `// TODO ticket #345` ont disparu du code.
|
|
- `AdminHeadcountGuard` existe comme service domaine, est consomme par `UserRbacProcessor` ET `UserProcessor`, teste en isolation.
|
|
- `UserRepositoryInterface::countAdmins()` existe et est implementee.
|
|
- `UserProcessor` intercepte `DELETE /api/users/{id}` et bloque la suppression du dernier admin avec un message explicite.
|
|
- `UserRbacProcessor` bloque la demotion du dernier admin global (en plus de la garde auto-suicide existante) avec un message distinct.
|
|
- `GET /api/me` retourne `effectivePermissions: string[]` et `isAdmin: boolean` dans son payload.
|
|
- `frontend/shared/composables/usePermissions.ts` expose `can`, `canAny`, `canAll`, stateless, bypasse si `isAdmin`.
|
|
- `frontend/shared/types/user-data.ts` inclut `isAdmin` et `effectivePermissions`.
|
|
- Tests unitaires PHP : `PermissionVoterTest`, `AdminHeadcountGuardTest`, `UserProcessorTest`, extension `UserRbacProcessorTest` — tous verts.
|
|
- Tests fonctionnels API : couverture 403 non-admin / 200 admin-ou-porteur sur chaque operation RBAC des 3 ressources, cas dernier admin PATCH et DELETE, `/api/me` enrichi.
|
|
- Test Vitest `usePermissions.test.ts` vert (ou TODO documentee si setup Vitest absent du projet).
|
|
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
|
|
- Aucun import croise entre modules ; tous les fichiers PHP crees vivent dans `Module/Core/` ou `tests/Module/Core/`, tous les fichiers front dans `frontend/shared/`.
|
|
- Le spec est mergee avec le code (meme PR #3 empilee sur `feat/rbac-api`) pour rester la reference du ticket.
|
|
|
|
## 17. Remarques de branche
|
|
|
|
- Branche de travail : `feat/rbac-voter`, tiree de `feat/rbac-api`.
|
|
- Pas de PR dediee : les commits #345 s'empilent sur la PR #3 existante ouverte vers `develop`.
|
|
- Une fois la PR #3 mergee, la branche finale de l'epic RBAC (`feat/rbac-admin-ui` pour #346) partira de `develop`.
|
|
|
|
## 18. Evolutions post-livraison — `UserRbacProcessor` defense in depth
|
|
|
|
Voir aussi : `docs/sites/ticket-02-spec.md` § 10 pour la problematique cote
|
|
Sites qui a motive cette evolution.
|
|
|
|
### 18.1 — Semantique `merge-patch+json` respectee
|
|
|
|
Le processor originel appliquait telles quelles les mutations produites par la
|
|
denormalisation API Platform. Or API Platform reinstancie par defaut une
|
|
`ArrayCollection` vide pour chaque propriete ManyToMany absente du payload,
|
|
ce qui viole la semantique `application/merge-patch+json` : les cles absentes
|
|
ne doivent PAS muter les proprietes correspondantes.
|
|
|
|
Consequence concrete du bug : un PATCH minimal comme `{ "isAdmin": true }`
|
|
detruisait silencieusement toutes les collections (`rbacRoles`,
|
|
`directPermissions`, `sites`) du user cible.
|
|
|
|
La garde `restoreAbsentCollections()` introduite dans `UserRbacProcessor`
|
|
resout cela en :
|
|
|
|
1. Injectant `RequestStack` pour lire le body JSON brut de la requete.
|
|
2. Decodant les cles effectivement envoyees par le client.
|
|
3. Pour chaque cle RBAC (`roles`, `directPermissions`, `sites`) absente du
|
|
payload : restaurant la collection a son etat d'origine a partir du
|
|
snapshot Doctrine (`PersistentCollection::getSnapshot()`), puis appelant
|
|
`takeSnapshot()` pour marquer la collection comme non-dirty (aucune query
|
|
`UPDATE` n'est emise sur les tables de jointure).
|
|
4. No-op si la cle est presente (la denormalisation fait foi).
|
|
|
|
Matrice finale :
|
|
|
|
| Payload | Effet |
|
|
|---------------------------------|-------------------------------------|
|
|
| Cle absente | Propriete preservee (BDD inchangee) |
|
|
| Cle presente = `[]` | Collection videe (vidage explicite) |
|
|
| Cle presente = `[...]` | Collection remplacee |
|
|
|
|
### 18.2 — Nouvelle operation `GET /users/{id}/rbac`
|
|
|
|
Le drawer d'edition (`UserRbacDrawer.vue`) ne peut plus dependre du payload
|
|
de liste `/api/users` (groupe `user:list`) pour initialiser l'etat `sites`
|
|
car ce groupe reste volontairement leger (cf. ticket Sites #02). Une
|
|
operation `Get` dediee est ajoutee, symetrique au `Patch` existant :
|
|
|
|
- URI : `/users/{id}/rbac`
|
|
- Security : `is_granted('core.users.manage')` (plus strict que `.view`)
|
|
- Groupe : `user:rbac:read` (contient `isAdmin`, `roles`, `directPermissions`,
|
|
`sites`).
|
|
|
|
Le drawer charge desormais ce GET en parallele des referentiels au moment
|
|
de l'ouverture, via un watch combine `[modelValue, user.id]` qui recharge
|
|
correctement si le user change sans fermeture du drawer entre-temps.
|
|
|
|
### 18.3 — Impact sur les tests
|
|
|
|
`UserRbacProcessorTest` : le constructor gagne un argument `RequestStack`.
|
|
Les tests existants injectent une `RequestStack` avec une `Request` vide
|
|
(body `""`), ce qui rend la garde no-op — le comportement des assertions
|
|
existantes est conserve. De nouveaux tests couvrent la garde :
|
|
|
|
- PATCH sans cle `sites` ne mute pas la collection d'origine.
|
|
- PATCH avec `sites: []` vide bien la collection (pas de regression du cas
|
|
"vidage explicite").
|
|
- PATCH avec `sites: [...]` remplace comme avant.
|
|
|
|
### 18.4 — Criteres de validation additionnels
|
|
|
|
- [ ] `GET /users/{id}/rbac` retourne 200 avec `core.users.manage`, 403 sans.
|
|
- [ ] Le payload contient `{ id, isAdmin, roles, directPermissions, sites }`.
|
|
- [ ] `PATCH /users/{id}/rbac` avec cle absente preserve la collection BDD.
|
|
- [ ] `PATCH /users/{id}/rbac` avec `[]` vide la collection et declenche
|
|
`ensureCurrentSiteConsistency` (cas sites).
|
|
- [ ] Les 228 tests PHPUnit existants passent apres ajout du parametre
|
|
`RequestStack` au constructor.
|