RBAC #345 - Voter Symfony + usePermissions composable #4
574
docs/rbac/ticket-345-spec.md
Normal file
574
docs/rbac/ticket-345-spec.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Ticket #345 - 3/5 - Voter Symfony + composable usePermissions (Full-stack)
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket remplace les gardes placeholder `is_granted('ROLE_ADMIN')` posees par le #344 sur les 13 operations API Platform du perimetre RBAC par des verifications metier basees sur les codes de permission livres au #343 (`core.users.view`, `core.roles.manage`, etc.). Il introduit le `PermissionVoter` Symfony qui interprete ces codes, avec un bypass total pour les utilisateurs `isAdmin = true` (decision gravee au #343 section 11). Il ferme la garde "dernier admin global" reportee par le #344 via un service domaine mutualise entre les chemins de mutation (`PATCH /users/{id}/rbac` et `DELETE /users/{id}`). Enfin il expose les permissions effectives de l'utilisateur courant via `/api/me` et livre le composable front `usePermissions()` qui les consomme.
|
||||
|
||||
A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applicatif reel, utilisable par les tickets #346 (ecrans d'admin RBAC) et #347 (UX des erreurs 403). Aucune interface d'administration n'est livree ici : le ticket est un socle full-stack sans ecran dedie.
|
||||
|
||||
## 2. Perimetre
|
||||
|
||||
### IN
|
||||
|
||||
- Ajouter la permission `core.roles.view` au catalogue `CoreModule::permissions()` et la synchroniser via `app:sync-permissions`. Documenter la regle par defaut "view + manage par ressource administrable" qui encadre les declarations futures.
|
||||
- Creer `PermissionVoter` Symfony qui :
|
||||
- supporte les attributs au format `module.resource[.sub].action` (regex explicite) sans interferer avec `ROLE_*`,
|
||||
- bypasse a `ACCESS_GRANTED` si `User::isAdmin() === true`,
|
||||
- sinon compare l'attribut a `User::getEffectivePermissions()`.
|
||||
- Remplacer les 13 `is_granted('ROLE_ADMIN')` places par le #344 (et les operations User heritees du profil pre-#344) par les codes metier adequats sur les entites `Permission`, `Role` et `User`. Supprimer les commentaires `// TODO ticket #345` en meme temps.
|
||||
- Creer un service domaine `AdminHeadcountGuard` dans `src/Module/Core/Domain/Security/` qui encapsule la regle "il doit toujours rester au moins un administrateur sur l'instance" et leve `LastAdminProtectionException` quand l'operation ferait tomber le compteur a zero.
|
||||
- Brancher le guard dans `UserRbacProcessor` (apres la garde auto-suicide existante) et dans un nouveau `UserProcessor` decorateur de `RemoveProcessor` qui intercepte `DELETE /api/users/{id}`.
|
||||
- Ajouter `UserRepositoryInterface::countAdmins(): int` et son implementation Doctrine.
|
||||
- Enrichir `/api/me` en exposant `effectivePermissions: list<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`.
|
||||
2503
frontend/package-lock.json
generated
2503
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.3",
|
||||
@@ -28,8 +30,11 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"vitest": "^4.1.4",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
65
frontend/shared/composables/__tests__/usePermissions.test.ts
Normal file
65
frontend/shared/composables/__tests__/usePermissions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePermissions } from '../usePermissions'
|
||||
|
||||
// Mock du store auth : le composable ne depend que de auth.user.
|
||||
const mockUser = vi.hoisted(() => ({
|
||||
value: null as { isAdmin: boolean; effectivePermissions: string[] } | null,
|
||||
}))
|
||||
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
get user() {
|
||||
return mockUser.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('usePermissions', () => {
|
||||
beforeEach(() => {
|
||||
mockUser.value = null
|
||||
})
|
||||
|
||||
it('refuse toute permission quand aucun utilisateur n\'est connecte', () => {
|
||||
const { can, canAny, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(false)
|
||||
expect(canAny(['core.users.view', 'core.roles.view'])).toBe(false)
|
||||
expect(canAll(['core.users.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('accorde toutes les permissions a un admin via le bypass', () => {
|
||||
mockUser.value = { isAdmin: true, effectivePermissions: [] }
|
||||
const { can, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
expect(can('module.inexistante.action')).toBe(true)
|
||||
expect(canAll(['a.b.c', 'd.e.f'])).toBe(true)
|
||||
})
|
||||
|
||||
it('accorde une permission presente dans effectivePermissions', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuse une permission absente pour un non-admin', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.roles.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('canAny retourne true si au moins un code matche', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { canAny } = usePermissions()
|
||||
expect(canAny(['core.roles.manage', 'core.users.view'])).toBe(true)
|
||||
expect(canAny(['core.roles.manage', 'core.permissions.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('canAll retourne true uniquement si tous les codes matchent', () => {
|
||||
mockUser.value = {
|
||||
isAdmin: false,
|
||||
effectivePermissions: ['core.users.view', 'core.roles.view'],
|
||||
}
|
||||
const { canAll } = usePermissions()
|
||||
expect(canAll(['core.users.view', 'core.roles.view'])).toBe(true)
|
||||
expect(canAll(['core.users.view', 'core.roles.manage'])).toBe(false)
|
||||
})
|
||||
})
|
||||
38
frontend/shared/composables/usePermissions.ts
Normal file
38
frontend/shared/composables/usePermissions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Composable d'autorisation cote front.
|
||||
*
|
||||
* Source de verite : `useAuthStore().user`, qui porte le payload /api/me
|
||||
* incluant `isAdmin` et `effectivePermissions` (tableau trie sans doublons).
|
||||
*
|
||||
* Regle de bypass dupliquee avec `PermissionVoter` (back) :
|
||||
* si `user.isAdmin === true`, toutes les permissions sont accordees.
|
||||
* Cette duplication est volontaire pour offrir un feedback UI immediat
|
||||
* sans aller-retour serveur. Si la regle de bypass change cote back
|
||||
* (decision architecturale #343 section 11), ce composable DOIT evoluer
|
||||
* en meme temps.
|
||||
*
|
||||
* Stateless : aucun ref module-level, tout passe par Pinia. Le reset est
|
||||
* assure automatiquement par `authStore.logout()` qui efface `user`.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
function can(code: string): boolean {
|
||||
const user = auth.user
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return user.effectivePermissions.includes(code)
|
||||
}
|
||||
|
||||
function canAny(codes: string[]): boolean {
|
||||
return codes.some(can)
|
||||
}
|
||||
|
||||
function canAll(codes: string[]): boolean {
|
||||
return codes.every(can)
|
||||
}
|
||||
|
||||
return { can, canAny, canAll }
|
||||
}
|
||||
@@ -2,4 +2,8 @@ export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
/** Vrai si l'utilisateur a le bypass admin total (voir ticket #343 section 11). */
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
}
|
||||
|
||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
13
makefile
13
makefile
@@ -59,6 +59,10 @@ nuxt-lint:
|
||||
nuxt-lint-fix:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run lint:fix"
|
||||
|
||||
# Lance les tests unitaires frontend (Vitest)
|
||||
nuxt-test:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run test"
|
||||
|
||||
delete_built_dir:
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
|
||||
@@ -82,6 +86,11 @@ migration-migrate:
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
# Synchronise le catalogue de permissions RBAC avec les declarations
|
||||
# des modules actifs (CoreModule::permissions() etc.). Idempotent.
|
||||
sync-permissions:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
@@ -90,6 +99,7 @@ db-reset:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists
|
||||
$(MAKE) migration-migrate
|
||||
$(MAKE) fixtures
|
||||
$(MAKE) sync-permissions
|
||||
|
||||
# Restart la bdd
|
||||
db-restart:
|
||||
@@ -127,5 +137,8 @@ php-cs-fixer-allow-risky:
|
||||
test:
|
||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||
|
||||
# Lance l'ensemble des tests (PHPUnit back + Vitest front)
|
||||
test-all: test nuxt-test
|
||||
|
||||
wait:
|
||||
sleep 10
|
||||
|
||||
@@ -32,8 +32,9 @@ final class CoreModule
|
||||
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 la liste des permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.permissions.view')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.permissions.view')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.permissions.view')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -35,31 +35,26 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
@@ -31,25 +32,24 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view')
|
||||
security: "is_granted('core.users.view')",
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view')
|
||||
security: "is_granted('core.users.view')",
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(
|
||||
name: 'user_rbac_patch',
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
// TODO ticket #345 : remplacer par is_granted('core.users.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
security: "is_granted('core.users.manage')",
|
||||
normalizationContext: ['groups' => ['user:rbac:read']],
|
||||
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||
processor: UserRbacProcessor::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
@@ -68,7 +68,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||
// Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac.
|
||||
// Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer
|
||||
// la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName).
|
||||
#[Groups(['user:rbac:write'])]
|
||||
private bool $isAdmin = false;
|
||||
|
||||
/**
|
||||
@@ -169,6 +172,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $roles;
|
||||
}
|
||||
|
||||
// Groupes de lecture + nom serialise explicite pour eviter que Symfony
|
||||
// ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin".
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
|
||||
#[SerializedName('isAdmin')]
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->isAdmin;
|
||||
@@ -245,6 +252,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
#[Groups(['me:read'])]
|
||||
public function getEffectivePermissions(): array
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Levee lorsqu'une operation mettrait fin a la presence d'au moins un
|
||||
* administrateur sur l'instance.
|
||||
*
|
||||
* L'invariant "au moins un admin doit exister" est protege au niveau du
|
||||
* domaine afin qu'aucun flux (API, CLI, import) ne puisse le contourner.
|
||||
* La traduction HTTP (422 ou 403) est laissee a la couche infrastructure.
|
||||
*/
|
||||
final class LastAdminProtectionException extends DomainException
|
||||
{
|
||||
/**
|
||||
* Construit l'exception avec un message par defaut ou un message fourni par l'appelant.
|
||||
*/
|
||||
public function __construct(string $message = 'Impossible : au moins un administrateur doit rester sur l\'instance.')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,12 @@ interface UserRepositoryInterface
|
||||
public function findByUsername(string $username): ?User;
|
||||
|
||||
public function save(User $user): void;
|
||||
|
||||
/**
|
||||
* Retourne le nombre d'utilisateurs ayant le flag isAdmin a true.
|
||||
*
|
||||
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
||||
* "au moins un administrateur doit rester sur l'instance".
|
||||
*/
|
||||
public function countAdmins(): int;
|
||||
}
|
||||
|
||||
64
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
64
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Gardien de l'invariant domaine : l'instance doit toujours conserver
|
||||
* au moins un utilisateur administrateur.
|
||||
*
|
||||
* Ce service est appele avant toute operation susceptible de reduire le
|
||||
* nombre d'admins (retrait du flag isAdmin, suppression d'un utilisateur).
|
||||
* Il compte les admins restants et leve LastAdminProtectionException si
|
||||
* le seuil minimum (1) serait franchi.
|
||||
*/
|
||||
final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
|
||||
{
|
||||
public function __construct(private readonly UserRepositoryInterface $userRepository) {}
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||
*
|
||||
* L'argument $user est accepte mais non utilise dans la logique de comptage :
|
||||
* l'appelant a deja determine que cet utilisateur va perdre son statut admin ;
|
||||
* le garde se contente de verifier qu'il en reste au moins un autre.
|
||||
* Le parametre est conserve pour la lisibilite du site d'appel et pour
|
||||
* permettre une evolution future (ex : journalisation, audit trail).
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void
|
||||
{
|
||||
$this->checkAdminHeadcount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||
*
|
||||
* Meme principe que ensureAtLeastOneAdminRemainsAfterDemotion() : $user
|
||||
* est accepte pour la symetrie du contrat et les evolutions futures,
|
||||
* mais le comptage ne depend pas de son identite.
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void
|
||||
{
|
||||
$this->checkAdminHeadcount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les administrateurs et leve une exception si le seuil minimum est atteint.
|
||||
*
|
||||
* La verification est volontairement conservative (<=1) pour couvrir
|
||||
* le cas defensif ou la base serait deja dans un etat incoherent (0 admin).
|
||||
*
|
||||
* @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1
|
||||
*/
|
||||
private function checkAdminHeadcount(): void
|
||||
{
|
||||
if ($this->userRepository->countAdmins() <= 1) {
|
||||
throw new LastAdminProtectionException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
|
||||
/**
|
||||
* Contrat du gardien de l'invariant "au moins un admin sur l'instance".
|
||||
*
|
||||
* Separer l'interface de l'implementation permet de tester unitairement
|
||||
* les processors qui dependent de ce garde sans instancier le repository.
|
||||
*/
|
||||
interface AdminHeadcountGuardInterface
|
||||
{
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor dedie a l'operation `DELETE /api/users/{id}`.
|
||||
*
|
||||
* Delegue la suppression au RemoveProcessor Doctrine decore apres avoir
|
||||
* applique la garde "dernier admin global" : si l'utilisateur cible est
|
||||
* le seul admin restant sur l'instance, la suppression est refusee pour
|
||||
* preserver l'invariant "au moins un administrateur reste toujours".
|
||||
*
|
||||
* La garde est portee par AdminHeadcountGuard (domaine), partagee avec
|
||||
* UserRbacProcessor qui gere le meme invariant sur le chemin PATCH /rbac.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final class UserProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof User) {
|
||||
// Ce processor est wire exclusivement sur l'operation Delete de User.
|
||||
// Si on arrive ici avec un autre type, c'est une misconfiguration.
|
||||
throw new LogicException(sprintf(
|
||||
'UserProcessor attend une instance de %s, %s recu.',
|
||||
User::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde dernier admin global : on ne verifie que si on supprime
|
||||
// effectivement un admin. La suppression d'un user standard n'a
|
||||
// aucun impact sur le compteur d'administrateurs.
|
||||
if ($data->isAdmin()) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -21,14 +23,12 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
* ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le
|
||||
* UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`.
|
||||
*
|
||||
* Gardes metier :
|
||||
* Gardes metier (dans l'ordre d'execution) :
|
||||
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
|
||||
* On compare l'etat entrant a l'etat d'origine via l'UnitOfWork Doctrine,
|
||||
* en restreignant la verification au couple "user courant == user cible".
|
||||
*
|
||||
* TODO ticket #345 : garde "dernier admin" globale via inventaire des admins
|
||||
* restants (empeche de retirer `isAdmin` au dernier admin de l'instance, meme
|
||||
* si ce n'est pas sa propre operation).
|
||||
* Cas particulier plus strict, avec message dedie.
|
||||
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
||||
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
||||
* AdminHeadcountGuardInterface.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
@@ -39,6 +39,7 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -56,19 +57,26 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
|
||||
$currentUser = $this->security->getUser();
|
||||
|
||||
// Garde auto-suicide : l'user courant ne peut pas retirer son propre
|
||||
// flag admin. On ne compare que si la cible == l'user courant.
|
||||
if ($currentUser instanceof User
|
||||
&& null !== $currentUser->getId()
|
||||
&& $currentUser->getId() === $data->getId()
|
||||
) {
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||
$willLoseAdmin = true === $wasAdmin && false === $data->isAdmin();
|
||||
|
||||
if (true === $wasAdmin && false === $data->isAdmin()) {
|
||||
throw new BadRequestHttpException(
|
||||
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||
);
|
||||
// Garde auto-suicide : cas particulier plus strict — l'user courant ne
|
||||
// peut pas retirer son propre flag admin, meme si d'autres admins existent.
|
||||
if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) {
|
||||
throw new BadRequestHttpException(
|
||||
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||
);
|
||||
}
|
||||
|
||||
// Garde dernier admin global : invariant general — impossible de retirer
|
||||
// isAdmin si cela laisserait l'instance sans administrateur.
|
||||
if ($willLoseAdmin) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les utilisateurs ayant le flag isAdmin a true.
|
||||
*
|
||||
* Utilise par AdminHeadcountGuard pour verifier que l'instance conserve
|
||||
* toujours au moins un administrateur apres une demote ou une suppression.
|
||||
*/
|
||||
public function countAdmins(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('u')
|
||||
->select('COUNT(u.id)')
|
||||
->where('u.isAdmin = true')
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter RBAC qui evalue les codes de permission metier au format
|
||||
* "module.resource.action" (ex: "core.users.view").
|
||||
*
|
||||
* - Ignore silencieusement les attributs non-RBAC (ROLE_*, IS_AUTHENTICATED_*, ...),
|
||||
* qui restent traites par les voters core de Symfony. Strategy 'affirmative'
|
||||
* par defaut : tant qu'un voter repond GRANTED, l'acces est accorde.
|
||||
* - Bypass total si l'utilisateur porte le flag isAdmin (decision architecturale
|
||||
* gravee au ticket #343 section 11 : is_admin est le seul levier technique
|
||||
* de bypass, jamais remplace par un check de role).
|
||||
* - Sinon, compare l'attribut aux permissions effectives de l'utilisateur
|
||||
* (union dedupliquee triee venant des roles et des permissions directes).
|
||||
*
|
||||
* @extends Voter<string, mixed>
|
||||
*/
|
||||
final class PermissionVoter extends Voter
|
||||
{
|
||||
/**
|
||||
* Regex de reconnaissance des codes de permission.
|
||||
*
|
||||
* Contraintes :
|
||||
* - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_).
|
||||
* - Au moins un point de separation (ecarte les attributs atomiques
|
||||
* type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY).
|
||||
* - Segments en snake_case minuscule coherents avec les permissions
|
||||
* declarees par les *Module::permissions() et validees par app:sync-permissions.
|
||||
*/
|
||||
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
// Token anonyme ou user d'un autre type : on refuse explicitement.
|
||||
// Les voters core (AuthenticatedVoter) se chargent deja du cas
|
||||
// "pas authentifie du tout".
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
// Bypass total : decision architecturale #343 section 11.
|
||||
// Cette regle est dupliquee cote front dans usePermissions()
|
||||
// et les deux doivent bouger ensemble si elle evolue un jour.
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($attribute, $user->getEffectivePermissions(), true);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Classe de base pour les tests fonctionnels API Platform du module Core.
|
||||
@@ -18,6 +22,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
* (cookie BEARER HTTP-only pose par lexik_jwt_authentication).
|
||||
* - `getEm()` : recupere l'EntityManager depuis le container courant.
|
||||
* A rappeler apres chaque createClient() car le kernel est reboote.
|
||||
* - `createUserWithPermission()` : cree un user non-admin jetable portant
|
||||
* une permission specifique via un role custom. Utile pour prouver qu'un
|
||||
* non-admin avec la permission obtient 200, et sans la permission 403.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -63,4 +70,64 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un utilisateur non-admin portant une permission specifique via un
|
||||
* role custom jetable. A utiliser dans les tests fonctionnels qui doivent
|
||||
* prouver qu'un non-admin avec la permission requise obtient 200, et
|
||||
* sans la permission obtient 403.
|
||||
*
|
||||
* Le user et le role sont persistes avec un suffixe aleatoire pour eviter
|
||||
* les collisions inter-tests. Le password est "testpass".
|
||||
*
|
||||
* Prerequis : la permission identifiee par $permissionCode doit exister en
|
||||
* base (seeder via `app:sync-permissions`). Si elle est introuvable, le test
|
||||
* echoue immediatement avec un message explicite.
|
||||
*
|
||||
* @param string $permissionCode Le code de la permission (ex: "core.users.view")
|
||||
*
|
||||
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
|
||||
*/
|
||||
protected function createUserWithPermission(string $permissionCode): array
|
||||
{
|
||||
if (!self::$kernel) {
|
||||
self::bootKernel();
|
||||
}
|
||||
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|Permission $permission */
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
|
||||
|
||||
self::assertNotNull(
|
||||
$permission,
|
||||
sprintf(
|
||||
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
|
||||
$permissionCode,
|
||||
),
|
||||
);
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = 'testuser_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
||||
$role->addPermission($permission);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
$em->persist($user);
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return ['username' => $username, 'password' => $password];
|
||||
}
|
||||
}
|
||||
|
||||
169
tests/Module/Core/Api/MeApiTest.php
Normal file
169
tests/Module/Core/Api/MeApiTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'endpoint GET /api/me.
|
||||
*
|
||||
* Verifie que la reponse inclut `isAdmin` et `effectivePermissions`
|
||||
* dans le groupe de serialisation `me:read`.
|
||||
*
|
||||
* Strategie de donnees :
|
||||
* - Les tests 1-3 s'appuient exclusivement sur les fixtures (admin/alice).
|
||||
* - Le test 4 cree un user jetable prefixe `test_me_` + role + permission,
|
||||
* purges en tearDown.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class MeApiTest extends AbstractApiTestCase
|
||||
{
|
||||
private const TEST_USER_PREFIX = 'test_me_';
|
||||
private const TEST_ROLE_PREFIX = 'test_me_';
|
||||
private const TEST_PERMISSION_PREFIX = 'test.me.';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'admin (isAdmin=true, role systeme sans permission explicite) doit
|
||||
* obtenir un payload /me avec isAdmin=true et effectivePermissions=[].
|
||||
*/
|
||||
public function testMeEndpointReturnsIsAdminAndEffectivePermissionsForAdmin(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertSame('admin', $data['username'], 'Le champ username doit etre "admin".');
|
||||
self::assertTrue($data['isAdmin'], 'isAdmin doit etre true pour l\'admin fixture.');
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertIsArray($data['effectivePermissions'], 'effectivePermissions doit etre un tableau JSON.');
|
||||
// Le role systeme admin n'a pas de permissions explicites : tableau vide attendu.
|
||||
self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour l\'admin sans permissions explicites.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur standard (isAdmin=false, role user sans permission) doit
|
||||
* obtenir isAdmin=false et effectivePermissions=[].
|
||||
*/
|
||||
public function testMeEndpointReturnsEmptyPermissionsForStandardUser(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertFalse($data['isAdmin'], 'isAdmin doit etre false pour alice.');
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour un user sans role avec permission.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Une requete non authentifiee sur /api/me doit retourner 401.
|
||||
*/
|
||||
public function testMeEndpointRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un user rattache a un role portant la permission `core.users.view` doit
|
||||
* retrouver cette permission dans effectivePermissions, triee alphabetiquement.
|
||||
*/
|
||||
public function testMeEndpointReturnsEffectivePermissionsForUserWithRolePermissions(): void
|
||||
{
|
||||
// --- Preparation des donnees de test ---
|
||||
self::bootKernel();
|
||||
$em = $this->getEm();
|
||||
|
||||
$this->cleanupTestData();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$permission = new Permission('test.me.core.users.view', 'View users (test me)', 'core');
|
||||
$em->persist($permission);
|
||||
|
||||
$role = new Role('test_me_viewer', 'Viewer (test me)', false);
|
||||
$role->addPermission($permission);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('test_me_viewer_user');
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, 'secret'));
|
||||
$user->addRbacRole($role);
|
||||
$em->persist($user);
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
// --- Appel API ---
|
||||
$client = $this->authenticatedClient('test_me_viewer_user', 'secret');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertContains(
|
||||
'test.me.core.users.view',
|
||||
$data['effectivePermissions'],
|
||||
'effectivePermissions doit contenir le code de permission du role attribue.',
|
||||
);
|
||||
|
||||
// Verifie le tri alphabetique (contrat spec section 9 ticket-343).
|
||||
$sorted = $data['effectivePermissions'];
|
||||
$copy = $sorted;
|
||||
sort($copy);
|
||||
self::assertSame($copy, $sorted, 'effectivePermissions doit etre trie alphabetiquement.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entites de test creees par les methodes ci-dessus.
|
||||
* Ordre : users d'abord (FK vers roles), puis roles, puis permissions.
|
||||
*/
|
||||
private function cleanupTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'exposition API Platform de l'entite Permission.
|
||||
@@ -172,9 +174,69 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
||||
|
||||
public function testListPermissionsAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.permissions.view doit pouvoir lister.
|
||||
$credentials = $this->createUserWithPermission('core.permissions.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testListPermissionsAsStandardUserReturns403(): void
|
||||
{
|
||||
// alice n'a aucune permission RBAC : acces refuse.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testGetPermissionAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Recupere l'id d'une permission existante pour construire l'URL GET item.
|
||||
$permission = $this->getEm()->getRepository(Permission::class)
|
||||
->findOneBy(['code' => 'test.core.users.view'])
|
||||
;
|
||||
self::assertNotNull($permission);
|
||||
|
||||
$credentials = $this->createUserWithPermission('core.permissions.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/permissions/'.$permission->getId());
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testGetPermissionAsStandardUserReturns403(): void
|
||||
{
|
||||
$permission = $this->getEm()->getRepository(Permission::class)
|
||||
->findOneBy(['code' => 'test.core.users.view'])
|
||||
;
|
||||
self::assertNotNull($permission);
|
||||
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/permissions/'.$permission->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function cleanupTestPermissions(): void
|
||||
{
|
||||
$this->getEm()->createQuery(
|
||||
$em = $this->getEm();
|
||||
|
||||
// Purge des users et roles jetables crees par createUserWithPermission().
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', 'testuser_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute();
|
||||
}
|
||||
|
||||
@@ -368,6 +368,85 @@ final class RoleApiTest extends AbstractApiTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
||||
|
||||
public function testListRolesAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.roles.view doit pouvoir lister les roles.
|
||||
$credentials = $this->createUserWithPermission('core.roles.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testListRolesAsUserWithOnlyManagePermissionReturns403(): void
|
||||
{
|
||||
// Un user avec uniquement core.roles.manage ne peut PAS lister (list/get
|
||||
// exige core.roles.view, cf. spec section 3 ticket-345).
|
||||
$credentials = $this->createUserWithPermission('core.roles.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testListRolesAsStandardUserReturns403(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsUserWithManagePermissionReturns201(): void
|
||||
{
|
||||
// Un non-admin portant core.roles.manage doit pouvoir creer un role.
|
||||
$credentials = $this->createUserWithPermission('core.roles.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$response = $client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_created_by_manager',
|
||||
'label' => 'Role cree par manager (test)',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
self::assertSame('test_created_by_manager', $data['code']);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsUserWithOnlyViewPermissionReturns403(): void
|
||||
{
|
||||
// Un user avec core.roles.view uniquement ne peut pas creer (POST exige .manage).
|
||||
$credentials = $this->createUserWithPermission('core.roles.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_shouldnotcreate',
|
||||
'label' => 'Ne doit pas etre cree',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsStandardUserReturns403(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_shouldnotcreate_alice',
|
||||
'label' => 'Ne doit pas etre cree',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les donnees de test (roles et permissions prefixees `test.`).
|
||||
* Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les
|
||||
|
||||
195
tests/Module/Core/Api/UserApiTest.php
Normal file
195
tests/Module/Core/Api/UserApiTest.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'exposition API Platform de l'entite User.
|
||||
*
|
||||
* Strategie :
|
||||
* - Les fixtures chargent 3 users : admin (is_admin=true), alice, bob.
|
||||
* - Les tests de lecture s'appuient sur les fixtures sans les modifier.
|
||||
* - Les tests de suppression et de guard "dernier admin" creent des users
|
||||
* additionnels via EntityManager, purges en tearDown.
|
||||
* - On ne supprime JAMAIS les users fixture (admin / alice / bob).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class UserApiTest extends AbstractApiTestCase
|
||||
{
|
||||
private const TEST_USER_PREFIX = 'test_';
|
||||
private const TEST_ROLE_PREFIX = 'test_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// --- Tests lecture collection ---
|
||||
|
||||
public function testListUsersAsAdminReturns200(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('member', $data);
|
||||
// Au moins 3 users fixture.
|
||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
||||
}
|
||||
|
||||
public function testListUsersAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.users.view doit pouvoir lister les users.
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testListUsersAsStandardUserReturns403(): void
|
||||
{
|
||||
// alice n'a aucune permission RBAC : acces refuse.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// --- Tests suppression ---
|
||||
|
||||
public function testDeleteNonAdminUserAsAdminReturns204(): void
|
||||
{
|
||||
// Confirme que la suppression d'un user non-admin fonctionne.
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$target = new User();
|
||||
$target->setUsername('test_deletable_user');
|
||||
$target->setIsAdmin(false);
|
||||
$target->setPassword($hasher->hashPassword($target, 'secret'));
|
||||
$em->persist($target);
|
||||
$em->flush();
|
||||
$targetId = $target->getId();
|
||||
$em->clear();
|
||||
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$client->request('DELETE', '/api/users/'.$targetId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// Verification cote base : le user n'existe plus.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNull($em->getRepository(User::class)->find($targetId));
|
||||
}
|
||||
|
||||
public function testDeleteSecondAdminReturns204(): void
|
||||
{
|
||||
// Quand il y a 2 admins, supprimer le second est autorise (garde non declenchee).
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$secondAdmin = new User();
|
||||
$secondAdmin->setUsername('test_second_admin');
|
||||
$secondAdmin->setIsAdmin(true);
|
||||
$secondAdmin->setPassword($hasher->hashPassword($secondAdmin, 'secret'));
|
||||
$em->persist($secondAdmin);
|
||||
$em->flush();
|
||||
$secondAdminId = $secondAdmin->getId();
|
||||
$em->clear();
|
||||
|
||||
// Auth en tant qu'admin fixture, supprime le second admin.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$client->request('DELETE', '/api/users/'.$secondAdminId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNull($em->getRepository(User::class)->find($secondAdminId));
|
||||
}
|
||||
|
||||
public function testDeleteLastAdminReturns400(): void
|
||||
{
|
||||
// Scenario "dernier admin global" : un seul admin existe (fixture admin).
|
||||
// Il tente de se supprimer lui-meme -> garde activee -> 400.
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|User $fixtureAdmin */
|
||||
$fixtureAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||
self::assertNotNull($fixtureAdmin, 'L\'user admin fixture doit exister.');
|
||||
$fixtureAdminId = $fixtureAdmin->getId();
|
||||
|
||||
// Garantit qu'il n'y a qu'un seul admin au moment du test :
|
||||
// s'assure que test_second_admin n'existe pas (tearDown le purge, mais
|
||||
// soyons defensifs si un test precedent n'a pas nettoye).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix AND u.username != :admin'
|
||||
)->setParameters(['prefix' => 'test_%', 'admin' => 'admin'])->execute();
|
||||
|
||||
// Auth en tant que l'admin fixture et tente l'auto-suppression.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('DELETE', '/api/users/'.$fixtureAdminId);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
|
||||
// Verification cote base : l'admin fixture doit toujours exister.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNotNull(
|
||||
$em->getRepository(User::class)->find($fixtureAdminId),
|
||||
'Le dernier admin ne doit PAS etre supprime.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteAsStandardUserReturns403(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|User $alice */
|
||||
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
self::assertNotNull($alice);
|
||||
|
||||
/** @var null|User $bob */
|
||||
$bob = $em->getRepository(User::class)->findOneBy(['username' => 'bob']);
|
||||
self::assertNotNull($bob);
|
||||
|
||||
// alice sans permission ne peut pas supprimer bob.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('DELETE', '/api/users/'.$bob->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entites de test creees par cette suite.
|
||||
* Ne touche JAMAIS aux fixtures (admin / alice / bob).
|
||||
*/
|
||||
private function cleanupTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Purge des users jetables crees par les tests (y compris testuser_ de createUserWithPermission).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
// Purge des roles jetables crees par createUserWithPermission.
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,40 @@ final class UserRbacApiTest extends AbstractApiTestCase
|
||||
self::assertFalse($reloaded->isAdmin());
|
||||
}
|
||||
|
||||
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
||||
|
||||
public function testPatchRbacAsUserWithManagePermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac.
|
||||
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||
self::assertNotNull($target);
|
||||
|
||||
$credentials = $this->createUserWithPermission('core.users.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isAdmin' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void
|
||||
{
|
||||
// Un user avec core.users.view uniquement ne peut pas ecrire via /rbac.
|
||||
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||
self::assertNotNull($target);
|
||||
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isAdmin' => true],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testPatchRbacSelfRemovingAdminReturns400(): void
|
||||
{
|
||||
// On utilise le user admin dedie (test_self_admin) pour ne pas
|
||||
|
||||
127
tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php
Normal file
127
tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuard;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires du gardien d'invariant AdminHeadcountGuard.
|
||||
*
|
||||
* Aucun acces base de donnees : UserRepositoryInterface est mocke.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class AdminHeadcountGuardTest extends TestCase
|
||||
{
|
||||
// ---------------------------------------------------------------
|
||||
// Demote (retrait du flag admin)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Autorise la demote quand il reste plus d'un admin (cas nominal).
|
||||
*/
|
||||
public function testAllowsDemotionWhenMoreThanOneAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(2);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
|
||||
// Aucune exception ne doit etre levee
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloque la demote quand il ne reste exactement qu'un admin.
|
||||
*/
|
||||
public function testBlocksDemotionWhenExactlyOneAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(1);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
|
||||
$this->expectException(LastAdminProtectionException::class);
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloque la demote de facon defensive si le compteur est a 0 (etat incoherent).
|
||||
*/
|
||||
public function testBlocksDemotionDefensivelyWhenZeroAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(0);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
|
||||
$this->expectException(LastAdminProtectionException::class);
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDemotion($user);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Deletion (suppression de l'utilisateur)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Autorise la suppression quand il reste plus d'un admin (cas nominal).
|
||||
*/
|
||||
public function testAllowsDeletionWhenMoreThanOneAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(2);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('bob');
|
||||
|
||||
// Aucune exception ne doit etre levee
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloque la suppression quand il ne reste exactement qu'un admin.
|
||||
*/
|
||||
public function testBlocksDeletionWhenExactlyOneAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(1);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('bob');
|
||||
|
||||
$this->expectException(LastAdminProtectionException::class);
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloque la suppression de facon defensive si le compteur est a 0 (etat incoherent).
|
||||
*/
|
||||
public function testBlocksDeletionDefensivelyWhenZeroAdmin(): void
|
||||
{
|
||||
$repo = $this->createMock(UserRepositoryInterface::class);
|
||||
$repo->method('countAdmins')->willReturn(0);
|
||||
|
||||
$guard = new AdminHeadcountGuard($repo);
|
||||
$user = new User();
|
||||
$user->setUsername('bob');
|
||||
|
||||
$this->expectException(LastAdminProtectionException::class);
|
||||
$guard->ensureAtLeastOneAdminRemainsAfterDeletion($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserProcessor : couvre la garde "dernier admin global"
|
||||
* et la delegation au RemoveProcessor Doctrine decore pour l'operation DELETE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class UserProcessorTest extends TestCase
|
||||
{
|
||||
private MockObject&ProcessorInterface $removeProcessor;
|
||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
private UserProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->removeProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
|
||||
$this->processor = new UserProcessor(
|
||||
$this->removeProcessor,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenUserIsNotAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
$user->setIsAdmin(false);
|
||||
|
||||
// La garde ne doit jamais etre appellee pour un non-admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($user, new Delete());
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenAdminButNotLast(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
// La garde est appelee et ne leve pas d'exception (il reste d'autres admins).
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testBlocksWhenDeletingLastAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
$exceptionMessage = 'Impossible : au moins un administrateur doit rester sur l\'instance.';
|
||||
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
->willThrowException(new LastAdminProtectionException($exceptionMessage))
|
||||
;
|
||||
|
||||
// La suppression ne doit pas etre executee si la garde echoue.
|
||||
$this->removeProcessor
|
||||
->expects($this->never())
|
||||
->method('process')
|
||||
;
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage($exceptionMessage);
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testFailFastOnInvalidDataType(): void
|
||||
{
|
||||
// Garde-fou contre une misconfiguration : ce processor est wire
|
||||
// exclusivement sur l'operation Delete de User.
|
||||
$this->adminHeadcountGuard->expects($this->never())->method('ensureAtLeastOneAdminRemainsAfterDeletion');
|
||||
$this->removeProcessor->expects($this->never())->method('process');
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('UserProcessor attend une instance de');
|
||||
|
||||
$this->processor->process(new stdClass(), new Delete());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
@@ -22,9 +24,9 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide" et la
|
||||
* delegation au PersistProcessor Doctrine decore pour les trois champs RBAC
|
||||
* (isAdmin, roles, directPermissions).
|
||||
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide", la
|
||||
* garde "dernier admin global" et la delegation au PersistProcessor Doctrine
|
||||
* decore pour les trois champs RBAC (isAdmin, roles, directPermissions).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -35,14 +37,16 @@ final class UserRbacProcessorTest extends TestCase
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private MockObject&UnitOfWork $unitOfWork;
|
||||
private MockObject&Security $security;
|
||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
private UserRbacProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
$this->security = $this->createMock(Security::class);
|
||||
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
$this->security = $this->createMock(Security::class);
|
||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
|
||||
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||
|
||||
@@ -50,19 +54,28 @@ final class UserRbacProcessorTest extends TestCase
|
||||
$this->persistProcessor,
|
||||
$this->entityManager,
|
||||
$this->security,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
|
||||
{
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$target->setIsAdmin(true);
|
||||
$target = $this->buildUser(42, 'alice', true);
|
||||
|
||||
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||
|
||||
// Cible != user courant : pas de lecture d'UnitOfWork necessaire.
|
||||
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||
// La cible gagne isAdmin (false -> true) : willLoseAdmin = false, donc
|
||||
// getOriginalEntityData est appele mais aucune garde ne bloque.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
@@ -146,14 +159,30 @@ final class UserRbacProcessorTest extends TestCase
|
||||
|
||||
public function testPatchAdminDemotingAnotherUserIsAllowed(): void
|
||||
{
|
||||
// Un admin qui retire isAdmin a quelqu'un d'autre : autorise.
|
||||
// Un admin qui retire isAdmin a quelqu'un d'autre : autorise si d'autres
|
||||
// admins existent (guard ne leve pas d'exception).
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
// Cible != user courant : pas de verification d'auto-suicide.
|
||||
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||
// La cible perd isAdmin (true -> false) : getOriginalEntityData est appele.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : d'autres admins existent.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($target)
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
@@ -210,6 +239,150 @@ final class UserRbacProcessorTest extends TestCase
|
||||
$this->processor->process(new stdClass(), new Patch());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests de la garde "dernier admin global"
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBlocksDemotionWhenLastAdminGlobally(): void
|
||||
{
|
||||
// L'admin courant A tente de retirer isAdmin a l'admin B (le dernier).
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde signale qu'il n'y aurait plus aucun admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
->willThrowException(new LastAdminProtectionException())
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Impossible : au moins un administrateur doit rester sur l\'instance.');
|
||||
|
||||
$this->processor->process($adminB, new Patch());
|
||||
}
|
||||
|
||||
public function testDelegatesDemotionWhenAdminsRemain(): void
|
||||
{
|
||||
// L'admin courant A retire isAdmin a l'admin B, mais d'autres admins existent.
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : il reste au moins un admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($adminB)
|
||||
->willReturn($adminB)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($adminB, new Patch());
|
||||
|
||||
self::assertSame($adminB, $result);
|
||||
}
|
||||
|
||||
public function testDoesNotCallGuardWhenIsAdminUntouched(): void
|
||||
{
|
||||
// PATCH qui ne touche pas isAdmin (reste false) : la garde ne doit pas etre appelee.
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// isAdmin reste false : willLoseAdmin = false, garde jamais appelee.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($target)
|
||||
->willReturn($target)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($target, new Patch());
|
||||
|
||||
self::assertSame($target, $result);
|
||||
}
|
||||
|
||||
public function testAutoSuicideTakesPrecedenceOverLastAdminGlobal(): void
|
||||
{
|
||||
// L'unique admin tente de se retirer lui-meme son propre flag.
|
||||
// La garde auto-suicide doit court-circuiter avant la garde dernier-admin.
|
||||
$self = $this->buildUser(1, 'admin', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($self);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($self)
|
||||
->willReturn([
|
||||
'id' => 1,
|
||||
'username' => 'admin',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// La garde dernier-admin ne doit jamais etre appelee : l'auto-suicide
|
||||
// court-circuite avant.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.');
|
||||
|
||||
$this->processor->process($self, new Patch());
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un User avec un id force via reflection (les mocks
|
||||
* d'UnitOfWork n'alimentent pas l'id tout seul).
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Infrastructure\Security\PermissionVoter;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* Tests unitaires du PermissionVoter RBAC.
|
||||
*
|
||||
* Le voter est teste via sa methode publique vote() qui retourne une des
|
||||
* trois constantes VoterInterface : ACCESS_GRANTED, ACCESS_DENIED, ACCESS_ABSTAIN.
|
||||
* - ACCESS_ABSTAIN : supports() a retourne false (attribut non-RBAC).
|
||||
* - ACCESS_GRANTED / ACCESS_DENIED : voteOnAttribute() a ete invoque.
|
||||
*
|
||||
* Aucun acces base de donnees : toutes les entites sont construites en memoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PermissionVoterTest extends TestCase
|
||||
{
|
||||
private PermissionVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new PermissionVoter();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Abstention : attributs non-RBAC
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule,
|
||||
* ne correspond pas au pattern snake_case minuscule avec point.
|
||||
*/
|
||||
public function testAbstainsOnRoleAdminAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['ROLE_ADMIN']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules,
|
||||
* pas de point de separation conforme au pattern RBAC.
|
||||
*/
|
||||
public function testAbstainsOnIsAuthenticatedAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur des attributs malformes : sans point ou avec
|
||||
* majuscules.
|
||||
*/
|
||||
#[DataProvider('malformedAttributeProvider')]
|
||||
public function testAbstainsOnMalformedAttribute(string $attribute): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, [$attribute]);
|
||||
|
||||
$this->assertSame(
|
||||
VoterInterface::ACCESS_ABSTAIN,
|
||||
$result,
|
||||
sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function malformedAttributeProvider(): array
|
||||
{
|
||||
return [
|
||||
'sans point' => ['nodot'],
|
||||
'majuscule milieu' => ['HAS.UPPERCASE'],
|
||||
'commence chiffre' => ['1core.users.view'],
|
||||
'chaine vide' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Refus : utilisateur non reconnu
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand le token ne porte pas une instance de User metier
|
||||
* (ex: InMemoryUser de Symfony).
|
||||
*/
|
||||
public function testDeniesWhenUserIsNotAUserEntity(): void
|
||||
{
|
||||
$inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']);
|
||||
$token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bypass admin
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces systematiquement a un administrateur, meme sans aucune
|
||||
* permission explicite assignee.
|
||||
*/
|
||||
public function testGrantsForAdminBypass(): void
|
||||
{
|
||||
// Admin sans role ni permission directe : le bypass doit suffire.
|
||||
$user = $this->buildUser(username: 'admin', isAdmin: true);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions effectives via role
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces quand l'utilisateur possede la permission exacte via un role.
|
||||
*/
|
||||
public function testGrantsWhenUserHasExactPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand l'utilisateur possede une permission differente de
|
||||
* celle demandee.
|
||||
*/
|
||||
public function testDeniesWhenUserLacksPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
// L'utilisateur a core.users.view mais pas core.roles.manage.
|
||||
$result = $this->voter->vote($token, null, ['core.roles.manage']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions directes (hors roles)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces via une permission directe (assignee sans passer par un role).
|
||||
*/
|
||||
public function testGrantsForDirectPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
|
||||
$user = $this->buildUser(username: 'bob', isAdmin: false);
|
||||
$user->addDirectPermission($permission);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construit un User metier minimal sans persistance.
|
||||
*/
|
||||
private function buildUser(string $username, bool $isAdmin): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin($isAdmin);
|
||||
// Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface.
|
||||
$user->setPassword('hashed_placeholder');
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user