Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions
6b4868b261 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 58s
2026-04-17 12:34:44 +00:00
e8c2789435 RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé

Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.

### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)

### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions

### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)

## Tickets Lesstime

- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur

## Test plan

- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: #7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-04-17 12:34:38 +00:00
49 changed files with 7926 additions and 377 deletions

View File

@@ -23,7 +23,6 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
@@ -90,6 +89,8 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0"
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
}
}

492
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bfd26e903d79f710cfe95452c05f2a25",
"content-hash": "75f8e672f2a401290886fbcf01befd3f",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -4988,180 +4988,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11018,6 +10844,322 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/browser-kit",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/browser-kit.git",
"reference": "f5a28fca785416cf489dd579011e74c831100cc3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/f5a28fca785416cf489dd579011e74c831100cc3",
"reference": "f5a28fca785416cf489dd579011e74c831100cc3",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/dom-crawler": "^7.4|^8.0"
},
"require-dev": {
"symfony/css-selector": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\BrowserKit\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/browser-kit/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/dom-crawler",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/dom-crawler.git",
"reference": "284ace90732b445b027728b5e0eec6418a17a364"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/284ace90732b445b027728b5e0eec6418a17a364",
"reference": "284ace90732b445b027728b5e0eec6418a17a364",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.0"
},
"require-dev": {
"symfony/css-selector": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\DomCrawler\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases DOM navigation for HTML and XML documents",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dom-crawler/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",

View File

@@ -1,6 +1,14 @@
api_platform:
title: Coltura API
version: 1.0.0
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
# Sans ces paths, le compile pass d'API Platform ne declare pas les
# services de filtres annotes (les filtres etaient silencieusement
# ignores sur Permission — cf. ticket #344).
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']

View File

@@ -8,6 +8,8 @@ declare(strict_types=1);
* This file defines the sidebar sections displayed in the frontend.
* Each item references the module that owns it via the `module` key.
* Items whose module is not active (see config/modules.php) are filtered out.
* Items may also declare a `permission` key (RBAC permission code) : the item
* is hidden from users who do not hold that permission.
*
* This config is decoupled from the modules themselves: you can freely
* move an item from one section to another without touching the module code.
@@ -32,6 +34,20 @@ return [
'icon' => 'mdi:cog-outline',
'module' => 'core',
],
[
'label' => 'sidebar.core.roles',
'to' => '/admin/roles',
'icon' => 'mdi:shield-account-outline',
'module' => 'core',
'permission' => 'core.roles.view',
],
[
'label' => 'sidebar.core.users',
'to' => '/admin/users',
'icon' => 'mdi:account-group-outline',
'module' => 'core',
'permission' => 'core.users.view',
],
[
'label' => 'sidebar.general.logout',
'to' => '/logout',

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.30'
app.version: '0.1.31'

View File

@@ -0,0 +1,275 @@
# Ticket #344 - 2/5 - API CRUD Roles & Permissions (Backend)
## 1. Objectif
Exposer via API Platform le socle RBAC livre par le ticket #343 (entites `Role`, `Permission`, relations `User->roles`/`directPermissions`, flag `isAdmin`). Ce ticket livre la surface HTTP minimale permettant :
- de lister et consulter les permissions synchronisees par `app:sync-permissions`,
- de gerer le cycle de vie des roles (CRUD) tout en protegeant les roles systeme,
- d'attribuer `isAdmin`, les roles RBAC et les permissions directes a un utilisateur sans polluer le groupe `user:write` (commit `0fc4e16`).
Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verification `is_granted('module.resource.action')` est traitee par le voter du ticket #345. A ce stade, les operations sont gardees par un simple `is_granted('ROLE_ADMIN')`, remplace au #345.
## 2. Perimetre
### IN
- Exposer l'entite `Permission` en API Platform en lecture seule (`GetCollection`, `Get`), groupe `permission:read`, filtres `module` et `orphan`.
- Exposer l'entite `Role` en API Platform avec CRUD complet (`GetCollection`, `Get`, `Post`, `Patch`, `Delete`), groupes `role:read` et `role:write`, filtre `isSystem`.
- Ajouter un processor `RoleProcessor` decorant `PersistProcessor` et `RemoveProcessor` pour :
- refuser la suppression d'un role systeme en traduisant `SystemRoleDeletionException` en `403`,
- empecher la mutation de `code` et `isSystem` sur un role systeme existant.
- Ajouter une operation nommee `user_rbac_patch` (`PATCH /api/users/{id}/rbac`) sur l'entite `User` avec son propre groupe `user:rbac:write` exposant `isAdmin`, `roles` et `directPermissions`. Laisser `user:write` propre pour les champs profil (compatible avec la decision de `0fc4e16`). Le nom explicite est indispensable : API Platform 4 identifie les operations par nom, un `new Patch` sans `name:` entrerait en collision avec l'operation profil existante.
- Ajouter un processor `UserRbacProcessor` qui persiste les mutations RBAC de l'utilisateur sans toucher au password hashing (decorator de `PersistProcessor`, pas du `UserPasswordHasherProcessor`).
- Ajouter sur `Role` les contraintes Symfony Validator : `UniqueEntity(fields: ['code'])`, `Assert\NotBlank` et `Assert\Regex` sur `code`, `Assert\NotBlank` sur `label` (cf. section 6).
- Garder toutes les operations sous `is_granted('ROLE_ADMIN')` avec un commentaire `// TODO ticket #345 : remplacer par is_granted('core.roles.manage')`.
- Tests PHPUnit unitaires (processors) et fonctionnels (`ApiTestCase`) couvrant les chemins nominaux et les cas 403/422.
### OUT
- Ticket `#345` : voter `PermissionVoter`, remplacement du `is_granted('ROLE_ADMIN')` par les codes de permission, composable front `usePermissions`.
- Ticket `#346` : ecrans d'administration front (liste/edition des roles et permissions).
- Ticket `#347` : UX des erreurs 403 et integration front de l'ecran de gestion des permissions utilisateur.
- Endpoint d'ecriture sur `Permission` : la table reste la propriete exclusive de `app:sync-permissions` (source de verite = code).
- Lecture des permissions effectives d'un `User` via `/api/me` : traitee au #345 en meme temps que le voter.
- Exposition d'un endpoint de bulk-assign permissions sur plusieurs utilisateurs : hors scope.
## 3. Fichiers a creer
### Infrastructure - Processors
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
### Tests unitaires
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
### Tests fonctionnels
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
## 4. Fichiers a modifier
### Entite `Permission`
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
- Normalization context : groupe `permission:read` uniquement.
- Pas de `denormalizationContext` (lecture seule).
- Security `is_granted('ROLE_ADMIN')` sur les deux operations (TODO #345).
- Ajouter `#[Groups(['permission:read'])]` sur `$id`, `$code`, `$label`, `$module`, `$orphan`. Pas d'ajout du groupe `role:read` : on laisse API Platform serialiser la relation `Role::$permissions` en IRIs par defaut, le front resoudra les details en 2 appels si necessaire (decision explicite pour garder les payloads petits et les permissions paginable independamment).
- Ajouter les filtres API Platform `SearchFilter` sur `module` (exact) et `BooleanFilter` sur `orphan`.
Extrait attendu :
```php
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['permission:read']],
),
new Get(
security: "is_granted('ROLE_ADMIN')",
normalizationContext: ['groups' => ['permission:read']],
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
```
### Entite `Role`
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
- Normalization context : `role:read`. Denormalization context : `role:write`.
- Processor `RoleProcessor::class` sur `Post`, `Patch` et `Delete`.
- Security `is_granted('ROLE_ADMIN')` sur les 5 operations (TODO #345).
- Groupes :
- `$id` : `role:read`.
- `$code` : `role:read`, `role:write`. L'immuabilite apres creation est portee par `RoleProcessor` (variante A, cf. section 5), pas par un decoupage de groupes.
- `$label` : `role:read`, `role:write`.
- `$description` : `role:read`, `role:write`.
- `$isSystem` : `role:read` (jamais writable via API).
- `$permissions` : `role:read`, `role:write`. Serialise en IRIs (comportement API Platform par defaut sur une relation ManyToMany).
- Filtre `BooleanFilter` sur `isSystem`.
- **Important** : le constructeur actuel `public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)` doit etre compatible avec la denormalisation API Platform sur `POST`. API Platform 4 resout les arguments du constructeur par nom de propriete denormalise. Verifier (ou adapter) que `isSystem` ne peut pas etre injecte par le POST car il n'est pas dans `role:write`.
### Entite `User`
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
```php
new Patch(
name: 'user_rbac_patch',
uriTemplate: '/users/{id}/rbac',
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
```
Le `name:` est OBLIGATOIRE : sans lui, API Platform 4 deduit un nom par defaut qui peut collisionner avec la `Patch` profil existante (meme classe, meme methode HTTP) et provoquer un ecrasement silencieux de la route `/api/users/{id}`.
- Ajouter le groupe `user:rbac:write` sur les proprietes :
- `$isAdmin`
- `$roles`
- `$directPermissions`
- Ne PAS toucher `user:write` : la decision de `0fc4e16` est confirmee par ce ticket.
Raison de l'endpoint dedie (option B) :
- Separation des preoccupations : un `PATCH /api/users/{id}` reste un endpoint "profil" ; la promotion admin et la gestion des permissions est un acte administratif explicite et tracable.
- Facilite future l'ajout d'un audit log dedie (`#355` audit log project) sur l'endpoint RBAC sans polluer l'audit profil.
- Contrat front simple : une seule route, un seul groupe, une seule validation.
## 5. Regles metier et cas limites
### Role
- **Creation (`POST /api/roles`)** :
- `code`, `label` obligatoires. `description` optionnel. `permissions` optionnel (tableau d'IRIs).
- `isSystem` est toujours `false` pour les roles crees via API (n'est pas dans `role:write`).
- Unicite du `code` geree par la contrainte DB `uniq_role_code` → 422 via `UniqueEntity` validator a ajouter sur l'entite (voir section 6).
- **Modification (`PATCH /api/roles/{id}`)** :
- `label`, `description`, `permissions` modifiables librement, y compris sur un role systeme (utile pour customiser l'apparence dans l'UI sans casser la relation).
- `code` **immuable apres creation** — strategie retenue (variante A) : un seul groupe `role:write` contenant `code`, et une garde centralisee dans `RoleProcessor`. Le processor compare la valeur entrante a l'etat d'origine via `UnitOfWork::getOriginalEntityData($role)['code']` ; si elle differe, leve `BadRequestHttpException` avec un message francais explicite. Regle unique et uniforme : roles systeme ET roles customs sont concernes. Justification : garder la regle metier dans le domaine applicatif plutot que dupliquer les groupes de serialisation.
- **Suppression (`DELETE /api/roles/{id}`)** :
- `RoleProcessor` appelle `$role->ensureDeletable()` avant de deleguer au `RemoveProcessor`.
- `SystemRoleDeletionException` est catchee et re-levee en `Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException` (403).
- Les relations `user_role` et `role_permission` sur ce role sont nettoyees automatiquement par le `ON DELETE CASCADE` des contraintes `FK_2DE8C6A3D60322AC` (`user_role.role_id`) et `FK_6F7DF886D60322AC` (`role_permission.role_id`) posees dans `migrations/Version20260414150034.php`. Aucun nettoyage manuel necessaire dans `RoleProcessor`. Verifier en test fonctionnel par un DELETE d'un role custom attache a un user, puis assert que le user existe toujours et que `user_role` est vide pour ce couple.
### Permission
- Lecture seule via API. Aucun endpoint de mutation.
- Si un admin veut forcer une permission sur un utilisateur, il passe par `directPermissions` de `User`.
### User (operation RBAC)
- `PATCH /api/users/{id}/rbac` n'accepte que `isAdmin`, `roles`, `directPermissions`. Tout autre champ dans le payload est ignore (comportement par defaut d'API Platform avec un `denormalizationContext` restreint).
- **Garde minimale auto-suicide** : `UserRbacProcessor` refuse (`BadRequestHttpException` 400) toute requete ou l'user cible est egal a l'user courant du `Security::getUser()` ET `isAdmin` passe de `true` a `false`. Sans cette garde, un admin peut se degrader seul et perdre acces a l'endpoint, creant une situation de recovery penible. C'est une garde locale et pragmatique, volontairement plus stricte que "le dernier admin" : on interdit l'auto-degradation, point. La garde "plus d'un admin restant" reste reportee au #345 ou un inventaire global fera sens avec le voter. TODO a placer dans le processor avec reference a #345.
- Le password n'est jamais touche par cet endpoint (contrairement a `UserPasswordHasherProcessor` sur `PATCH /api/users/{id}`).
## 6. Validation
- Ajouter sur `Role` une contrainte `#[UniqueEntity(fields: ['code'])]` pour un 422 propre au lieu d'un 500 SQL en cas de conflit.
- Ajouter sur `Role::$code` un `#[Assert\NotBlank]` et un `#[Assert\Regex('/^[a-z][a-z0-9_]*$/')]` (meme convention que les permissions).
- Ajouter sur `Role::$label` un `#[Assert\NotBlank]`.
## 7. Plan de tests
### Unitaires
**`RoleProcessorTest`**
- `process()` d'un role non-systeme en DELETE delegue au `RemoveProcessor` sans lever.
- `process()` d'un role systeme en DELETE leve `AccessDeniedHttpException` (403) et n'appelle pas le decorator.
- `process()` d'un role systeme en PATCH dont le `code` a change leve `BadRequestHttpException`.
- `process()` d'un role systeme en PATCH dont seuls `label`/`permissions` changent delegue au `PersistProcessor`.
- `process()` d'un role non-systeme en POST delegue au `PersistProcessor`.
**`UserRbacProcessorTest`**
- `process()` persiste un user avec `isAdmin = true` via le decorator.
- `process()` persiste une collection de `roles` mise a jour.
- `process()` ne declenche jamais le hashing de password (verifier que `UserPasswordHasherProcessor` n'est pas dans la chaine).
### Fonctionnels (`ApiTestCase`)
**`PermissionApiTest`**
- `GET /api/permissions` en tant qu'admin retourne la liste des permissions synchronisees.
- `GET /api/permissions?module=core` filtre par module.
- `GET /api/permissions?orphan=true` retourne uniquement les orphelines.
- `GET /api/permissions/{id}` retourne les champs attendus (groupe `permission:read`).
- `POST /api/permissions` en tant qu'admin retourne `405 Method Not Allowed`.
- `GET /api/permissions` non authentifie retourne `401`.
- `GET /api/permissions` en tant que user standard retourne `403`.
**`RoleApiTest`**
- `POST /api/roles` avec `{code, label, description}` retourne `201` et persiste `isSystem = false`.
- `POST /api/roles` avec un `code` deja utilise retourne `422`.
- `POST /api/roles` avec un `code` invalide (`MAJ`, `space`) retourne `422`.
- `PATCH /api/roles/{id}` sur un role custom modifie `label` et ajoute des permissions via IRIs → `200`.
- `PATCH /api/roles/{id}` sur le role `admin` (systeme) modifiant seulement `label``200`.
- `PATCH /api/roles/{id}` sur le role `admin` tentant de modifier `code``400`.
- `DELETE /api/roles/{id}` sur un role custom → `204`.
- `DELETE /api/roles/{id}` sur le role `admin``403` avec `SystemRoleDeletionException` traduite.
- `DELETE /api/roles/{id}` d'un role custom attache a un user : le user reste, la relation `user_role` est nettoyee par le CASCADE.
- Toute operation sans auth retourne `401`.
- Toute operation en tant que user standard retourne `403`.
**`UserRbacApiTest`**
- `PATCH /api/users/{id}/rbac` en tant qu'admin avec `{isAdmin: true}` promeut le user.
- `PATCH /api/users/{id}/rbac` avec `{roles: [IRI...]}` remplace la collection de roles RBAC.
- `PATCH /api/users/{id}/rbac` avec `{directPermissions: [IRI...]}` remplace les permissions directes.
- `PATCH /api/users/{id}/rbac` en tant que user standard retourne `403`.
- `PATCH /api/users/{id}/rbac` non authentifie retourne `401`.
- `PATCH /api/users/{id}/rbac` avec un champ `username` dans le payload n'est pas persiste (denormalization context restreint).
- `PATCH /api/users/{id}` sans `/rbac` avec `{isAdmin: true}` ne modifie PAS `isAdmin` (confirme la decision `0fc4e16`).
## 8. Securite et traduction d'exceptions
- `SystemRoleDeletionException``AccessDeniedHttpException` (403) dans `RoleProcessor` (pas via un listener global : on garde la traduction locale au perimetre RBAC).
- `BadRequestHttpException` pour la mutation de `code` sur un role systeme : message explicite en francais, dans le payload Hydra `hydra:description`.
- Toutes les routes ont pour l'instant `security: "is_granted('ROLE_ADMIN')"`. Un commentaire `// TODO ticket #345` doit etre present sur chaque attribut pour faciliter le remplacement.
## 9. Conventions et architecture
- Respect strict du modular monolith : tous les fichiers crees vivent dans `src/Module/Core/` ou `tests/Module/Core/`. Aucun import depuis un autre module.
- `declare(strict_types=1)` en tete des nouveaux fichiers.
- Commentaires PHP en francais, identifiants anglais (`CLAUDE.md`).
- Processors branches via l'autoconfiguration Symfony ; aucun wiring manuel dans `services.yaml` attendu si le constructeur est injecte proprement.
- Pattern de decorator : utiliser `#[AsDecorator]` ou `#[Autoconfigure]` pour brancher le processor en tant que decorator du `PersistProcessor` API Platform, selon le pattern deja utilise par `UserPasswordHasherProcessor`.
- Aucune nouvelle entree necessaire dans `config/modules.php` ni `config/sidebar.php`.
## 10. Ordre d'execution recommande
1. Ajouter l'attribut `#[ApiResource]` et les `#[Groups]` sur `Permission`. Ecrire `PermissionApiTest`.
2. Ajouter les contraintes Validator sur `Role`. Ajouter `#[ApiResource]` et les `#[Groups]` sur `Role` **sans** processor dans un premier temps pour valider le CRUD nominal.
3. Creer `RoleProcessor` et le brancher en decorator. Ajouter les gardes systeme. Ecrire `RoleProcessorTest` + cas `RoleApiTest`.
4. Creer `UserRbacProcessor`. Ajouter l'operation `/users/{id}/rbac` et le groupe `user:rbac:write` sur `User`. Ecrire `UserRbacProcessorTest` + `UserRbacApiTest`.
5. `make test` complet + `make php-cs-fixer-allow-risky`.
6. Documentation : referencer ce spec dans `docs/rbac/` et mettre a jour le fil conducteur RBAC si un index existe.
## 11. Risques et points d'attention
- **Constructeur de `Role` et denormalisation POST** : API Platform 4 resout les arguments du constructeur par nom ; `isSystem` est dans la signature mais pas dans `role:write`, donc un client ne peut pas l'injecter — a verifier par un test explicite ("POST avec `isSystem: true` est ignore").
- **`code` immuable** : strategie retenue (garde dans processor) simple mais demande une lecture de l'etat initial du role avant persistance. Utiliser `UnitOfWork::getOriginalEntityData()` pour recuperer la valeur d'origine proprement.
- **Cascade de delete role → user_role** : depend de `ON DELETE CASCADE` pose par la migration #343. Verifier explicitement en test fonctionnel qu'aucune `ForeignKeyConstraintViolationException` ne remonte.
- **`UniqueEntity` sur `code`** : ne couvre pas les conflits en race condition, la DB reste la garde ultime. Acceptable.
- **Pas de filtre sur le `module` de Permission cote front** au #346 sans le filtre API : s'assurer que le filtre est bien pose ici.
- **Auto-retrait du dernier admin** : garde d'**auto-suicide** posee dans `UserRbacProcessor` (un admin ne peut pas se degrader lui-meme, cf. section 5). La garde "plus d'un admin restant" au niveau global reste reportee au voter #345.
- **Infra de test fonctionnel (fixtures et isolation)** : les tests `*ApiTest` dependent de la presence en base des roles systeme `admin` et `user`. L'infra actuelle doit fournir soit un reload des fixtures par classe de test, soit `DAMADoctrineTestBundle` pour transactionner chaque test. A verifier au debut de l'etape 1 de l'ordre d'execution ; si absent, ajouter un trait de bootstrap minimal `RbacFixturesTrait` qui insere les deux roles systeme avant chaque classe de test (pas par test, trop couteux). Ne pas bloquer le ticket sur cette question, adapter au vol.
## 12. Criteres d'acceptation (DoD)
- `GET /api/permissions` et `GET /api/permissions/{id}` fonctionnent, filtres `module` et `orphan` operationnels.
- CRUD complet sur `/api/roles` operationnel, avec `isSystem` en lecture seule cote API.
- `DELETE /api/roles/{admin_id}` retourne `403` avec un message metier.
- `PATCH /api/roles/{admin_id}` autorise la modification de `label`/`permissions` mais refuse la modification de `code` avec `400`.
- `PATCH /api/users/{id}/rbac` permet de modifier `isAdmin`, `roles` et `directPermissions` ; `PATCH /api/users/{id}` (profil) ne les modifie jamais.
- Les operations API sont gardees par `is_granted('ROLE_ADMIN')` et commentees avec la TODO pointant vers #345.
- `make test` passe ; `make php-cs-fixer-allow-risky` ne laisse aucun delta.
- Aucun import croise entre modules ; tous les fichiers crees vivent dans `Module/Core/` ou `tests/Module/Core/`.
- Le spec est mergee avec le code (meme PR ou PR precedente) pour rester la reference du ticket.
## 13. Remarques de branche
- Le ticket enonce "Branche a creer : `feat/rbac-api` depuis develop apres merge de #2".
- Branche courante locale : `feat/rbac-core`. A confirmer avec l'utilisateur si PR #2 est mergee : si oui, se rebaser sur `develop` et creer `feat/rbac-api` propre ; sinon, empiler `feat/rbac-api` sur `feat/rbac-core` en attendant le merge.

View 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`.

View File

@@ -22,6 +22,10 @@
"commercial": {
"section": "Commercial",
"suppliers": "Répertoire fournisseurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs"
}
},
"dashboard": {
@@ -56,5 +60,65 @@
"auth": {
"logout": "Deconnexion reussie"
}
},
"admin": {
"roles": {
"title": "Gestion des rôles",
"newRole": "Nouveau rôle",
"editRole": "Modifier le rôle",
"createRole": "Créer un rôle",
"noRoles": "Aucun rôle configuré",
"table": {
"label": "Libellé",
"code": "Code",
"permissions": "Permissions",
"system": "Système"
},
"form": {
"label": "Libellé",
"code": "Code",
"description": "Description",
"permissions": "Permissions"
},
"delete": {
"title": "Supprimer le rôle",
"message": "Êtes-vous sûr de vouloir supprimer le rôle \"{label}\" ? Cette action est irréversible.",
"systemTooltip": "Rôle système non supprimable"
},
"toast": {
"created": "Rôle créé avec succès",
"updated": "Rôle mis à jour avec succès",
"deleted": "Rôle supprimé avec succès"
},
"permissions": {
"selectAll": "Tout selectionner",
"noPermissions": "Aucune permission disponible"
}
},
"users": {
"title": "Gestion des utilisateurs",
"noUsers": "Aucun utilisateur",
"table": {
"username": "Nom d'utilisateur",
"admin": "Administrateur",
"roles": "Roles",
"directPermissions": "Permissions directes"
},
"drawer": {
"title": "Permissions de {username}",
"selfWarning": "Vous modifiez vos propres droits",
"adminToggle": "Administrateur (bypass total)",
"rolesSection": "Rôles",
"directPermissionsSection": "Permissions directes",
"summarySection": "Résumé des permissions effectives",
"noEffectivePermissions": "Aucune permission effective",
"sourceRole": "via {role}",
"sourceDirect": "Direct",
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin"
},
"toast": {
"updated": "Permissions mises à jour avec succès"
}
}
}
}

View File

@@ -0,0 +1,68 @@
<template>
<div>
<div v-if="permissions.length === 0" class="text-sm text-neutral-400">
{{ t('admin.users.drawer.noEffectivePermissions') }}
</div>
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
<div
v-for="perm in groupedPermissions"
:key="perm.module"
class="px-4 py-2"
>
<!-- En-tête du module -->
<p class="text-xs font-semibold uppercase text-neutral-400 mb-1">
{{ perm.module }}
</p>
<div
v-for="item in perm.items"
:key="item.code"
class="flex items-center justify-between py-1"
>
<span class="text-sm text-neutral-700">{{ item.label }}</span>
<div class="flex gap-1">
<span
v-for="source in item.sources"
:key="source"
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
source === t('admin.users.drawer.sourceDirect')
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
]"
>
{{ source }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { EffectivePermission } from '~/shared/types/rbac'
const { t } = useI18n()
const props = defineProps<{
permissions: EffectivePermission[]
}>()
// Grouper par module pour l'affichage
interface PermissionModuleGroup {
module: string
items: EffectivePermission[]
}
const groupedPermissions = computed<PermissionModuleGroup[]>(() => {
const groups = new Map<string, EffectivePermission[]>()
for (const perm of props.permissions) {
const list = groups.get(perm.module) || []
list.push(perm)
groups.set(perm.module, list)
}
return Array.from(groups.entries())
.map(([module, items]) => ({ module, items }))
.sort((a, b) => a.module.localeCompare(b.module))
})
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="rounded-lg border border-neutral-200 overflow-hidden">
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
<MalioCheckbox
:id="`group-${module}`"
:label="moduleLabel"
:model-value="allSelected"
label-class="font-semibold text-sm text-neutral-700 capitalize"
@update:model-value="toggleAll"
/>
<span class="ml-auto text-xs text-neutral-400">
{{ selectedCount }}/{{ permissions.length }}
</span>
</div>
<!-- Liste des permissions individuelles -->
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
<MalioCheckbox
v-for="perm in permissions"
:key="perm.id"
:id="`perm-${perm.id}`"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Permission } from '~/shared/types/rbac'
const props = defineProps<{
module: string
moduleLabel: string
permissions: Permission[]
selectedIds: Set<number>
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
toggleAll: [module: string, selected: boolean]
}>()
// Nombre de permissions selectionnees dans ce groupe
const selectedCount = computed(() =>
props.permissions.filter(p => props.selectedIds.has(p.id)).length
)
// Vrai si toutes les permissions du groupe sont selectionnees
const allSelected = computed(() =>
props.permissions.length > 0 && selectedCount.value === props.permissions.length
)
// Emet l'evenement de bascule pour une permission individuelle
function togglePermission(id: number, selected: boolean) {
emit('toggle', id, selected)
}
// Emet l'evenement de bascule pour toutes les permissions du groupe
function toggleAll(selected: boolean) {
emit('toggleAll', props.module, selected)
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.roles.delete.title') }}
</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ t('admin.roles.delete.message', { label: roleLabel }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="cancel"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
roleLabel: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
// Ferme la modale sans confirmer
function cancel() {
emit('update:modelValue', false)
}
// Emet l'evenement de confirmation de suppression
function confirm() {
emit('confirm')
}
// Fermer la modale avec la touche Escape
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<!-- Champs du role -->
<MalioInputText
v-model="form.label"
:label="t('admin.roles.form.label')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.code"
:label="t('admin.roles.form.code')"
input-class="w-full"
required
:readonly="isEditMode"
/>
<MalioInputTextArea
v-model="form.description"
:label="t('admin.roles.form.description')"
input-class="w-full"
/>
<!-- Permissions groupees par module -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.roles.form.permissions') }}
</h4>
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<div class="flex flex-col gap-4">
<PermissionGroup
v-for="group in permissionsByModule"
:key="group.module"
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedPermissionIds"
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving"
@click="handleSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Permission, Role } from '~/shared/types/rbac'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n()
const api = useApi()
const props = defineProps<{
modelValue: boolean
role: Role | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
const allPermissions = ref<Permission[]>([])
const form = ref({
label: '',
code: '',
description: '',
})
const selectedPermissionIds = ref(new Set<number>())
const isEditMode = computed(() => props.role !== null)
// Grouper les permissions par module
const permissionsByModule = computed<PermissionModule[]>(() => {
const groups = new Map<string, Permission[]>()
for (const perm of allPermissions.value) {
if (perm.orphan) continue
const list = groups.get(perm.module) || []
list.push(perm)
groups.set(perm.module, list)
}
return Array.from(groups.entries())
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
// Charger les permissions au montage
async function loadPermissions() {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ 'orphan': false, itemsPerPage: 999 },
{ toast: false },
)
allPermissions.value = data.member
}
// Remplir le formulaire quand le role change
watch(() => props.role, (role) => {
if (role) {
form.value.label = role.label
form.value.code = role.code
form.value.description = role.description || ''
selectedPermissionIds.value = new Set(role.permissions.map(p => {
// L'API peut retourner des objets Permission ou des IRIs string
if (typeof p === 'string') {
return Number(p.split('/').pop())
}
return p.id
}))
} else {
form.value.label = ''
form.value.code = ''
form.value.description = ''
selectedPermissionIds.value = new Set()
}
}, { immediate: true })
// Charger les permissions quand le drawer s'ouvre
watch(() => props.modelValue, (open) => {
if (open) loadPermissions()
})
// Basculer une permission individuelle
function handleTogglePermission(id: number, selected: boolean) {
const ids = new Set(selectedPermissionIds.value)
if (selected) {
ids.add(id)
} else {
ids.delete(id)
}
selectedPermissionIds.value = ids
}
// Basculer toutes les permissions d'un module
function handleToggleAll(module: string, selected: boolean) {
const ids = new Set(selectedPermissionIds.value)
const group = permissionsByModule.value.find(g => g.module === module)
if (!group) return
for (const perm of group.permissions) {
if (selected) {
ids.add(perm.id)
} else {
ids.delete(perm.id)
}
}
selectedPermissionIds.value = ids
}
// Sauvegarder le role (creation ou edition)
async function handleSave() {
saving.value = true
try {
const permissions = Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`)
if (isEditMode.value && props.role) {
// Le code est immuable apres creation (garde backend RoleProcessor)
await api.patch(`/roles/${props.role.id}`, {
label: form.value.label,
description: form.value.description || null,
permissions,
}, {
toastSuccessMessage: t('admin.roles.toast.updated'),
})
} else {
await api.post('/roles', {
label: form.value.label,
code: form.value.code,
description: form.value.description || null,
permissions,
}, {
toastSuccessMessage: t('admin.roles.toast.created'),
})
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,259 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="flex flex-col gap-6 p-4">
<!-- Avertissement auto-edition -->
<div
v-if="isSelfEdit"
class="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
>
<Icon name="mdi:alert-outline" class="size-5 shrink-0" />
{{ t('admin.users.drawer.selfWarning') }}
</div>
<!-- Toggle Administrateur -->
<MalioCheckbox
id="admin-toggle"
:label="t('admin.users.drawer.adminToggle')"
:model-value="form.isAdmin"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="form.isAdmin = $event"
/>
<!-- Section Roles -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.rolesSection') }}
</h4>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="role in allRoles"
:key="role.id"
:id="`role-${role.id}`"
:label="role.label"
:model-value="selectedRoleIds.has(role.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleRole(role.id, val)"
/>
</div>
</div>
<!-- Section Permissions directes -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.directPermissionsSection') }}
</h4>
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }}
</div>
<div class="flex flex-col gap-4">
<PermissionGroup
v-for="group in permissionsByModule"
:key="group.module"
:module="group.module"
:module-label="group.module"
:permissions="group.permissions"
:selected-ids="selectedDirectPermissionIds"
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div>
<!-- Section Resume permissions effectives -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.summarySection') }}
</h4>
<EffectivePermissions :permissions="effectivePermissions" />
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving"
@click="handleSave"
/>
</div>
</div>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n()
const api = useApi()
const auth = useAuthStore()
const props = defineProps<{
modelValue: boolean
user: UserListItem | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
}>()
const saving = ref(false)
const allRoles = ref<Role[]>([])
const allPermissions = ref<Permission[]>([])
const form = ref({ isAdmin: false })
const selectedRoleIds = ref(new Set<number>())
const selectedDirectPermissionIds = ref(new Set<number>())
// Detecter l'auto-edition
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
// Extraire un ID depuis une IRI API Platform
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
// Grouper les permissions par module (pour les checkboxes)
const permissionsByModule = computed<PermissionModule[]>(() => {
const groups = new Map<string, Permission[]>()
for (const perm of allPermissions.value) {
if (perm.orphan) continue
const list = groups.get(perm.module) || []
list.push(perm)
groups.set(perm.module, list)
}
return Array.from(groups.entries())
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
// Calculer les permissions effectives avec leurs sources
const effectivePermissions = computed<EffectivePermission[]>(() => {
const permMap = new Map<number, Permission>()
for (const p of allPermissions.value) {
if (!p.orphan) permMap.set(p.id, p)
}
// Construire la map permissionId -> sources[]
const result = new Map<number, string[]>()
// Permissions heritees des roles
for (const roleId of selectedRoleIds.value) {
const role = allRoles.value.find(r => r.id === roleId)
if (!role) continue
for (const p of role.permissions) {
const pid = typeof p === 'string' ? iriToId(p) : p.id
const sources = result.get(pid) || []
sources.push(t('admin.users.drawer.sourceRole', { role: role.label }))
result.set(pid, sources)
}
}
// Permissions directes
for (const pid of selectedDirectPermissionIds.value) {
const sources = result.get(pid) || []
sources.push(t('admin.users.drawer.sourceDirect'))
result.set(pid, sources)
}
// Construire la liste finale
return Array.from(result.entries())
.map(([pid, sources]) => {
const perm = permMap.get(pid)
if (!perm) return null
return { code: perm.code, label: perm.label, module: perm.module, sources }
})
.filter((p): p is EffectivePermission => p !== null)
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles et permissions
async function loadData() {
const [rolesData, permsData] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
}
// Remplir le formulaire quand le user change
watch(() => props.user, (user) => {
if (user) {
form.value.isAdmin = user.isAdmin
selectedRoleIds.value = new Set(user.roles.map(iriToId))
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
} else {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
}
}, { immediate: true })
// Charger les donnees quand le drawer s'ouvre
watch(() => props.modelValue, (open) => {
if (open) loadData()
})
function toggleRole(id: number, selected: boolean) {
const ids = new Set(selectedRoleIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedRoleIds.value = ids
}
function handleTogglePermission(id: number, selected: boolean) {
const ids = new Set(selectedDirectPermissionIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedDirectPermissionIds.value = ids
}
function handleToggleAll(module: string, selected: boolean) {
const ids = new Set(selectedDirectPermissionIds.value)
const group = permissionsByModule.value.find(g => g.module === module)
if (!group) return
for (const perm of group.permissions) {
if (selected) ids.add(perm.id)
else ids.delete(perm.id)
}
selectedDirectPermissionIds.value = ids
}
async function handleSave() {
if (!props.user) return
saving.value = true
try {
await api.patch(`/users/${props.user.id}/rbac`, {
isAdmin: form.value.isAdmin,
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
}, {
toastSuccessMessage: t('admin.users.toast.updated'),
})
// Rafraichir les donnees du user courant si auto-edition
if (isSelfEdit.value) {
await auth.refreshUser()
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.roles.title') }}
</h1>
<MalioButton
v-if="can('core.roles.manage')"
:label="t('admin.roles.newRole')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des roles -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="roleItems"
:total-items="roles.length"
:row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick"
>
<template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span>
</template>
<template #cell-permissions="{ item }">
{{ item.permissions }}
</template>
<template #cell-system="{ item }">
<span
v-if="item.isSystem"
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
>
{{ t('admin.roles.table.system') }}
</span>
</template>
</MalioDataTable>
<!-- Drawer creation/edition -->
<RoleDrawer
v-model="drawerOpen"
:role="selectedRole"
@saved="onRoleSaved"
@delete="onDeleteRequest"
/>
<!-- Modale de suppression -->
<RoleDeleteModal
v-model="deleteModalOpen"
:role-label="roleToDelete?.label ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
const roles = ref<Role[]>([])
const loading = ref(false)
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
{ key: 'code', label: t('admin.roles.table.code') },
{ key: 'permissions', label: t('admin.roles.table.permissions') },
{ key: 'system', label: t('admin.roles.table.system') },
]
// Transformer les roles en items compatibles MalioDataTable
const roleItems = computed(() =>
roles.value.map(role => ({
id: role.id,
label: role.label,
code: role.code,
permissions: role.permissions.length,
isSystem: role.isSystem,
system: '', // colonne geree par le slot
}))
)
function getRoleById(id: number): Role | undefined {
return roles.value.find(r => r.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const role = getRoleById(item.id as number)
if (role) openEditDrawer(role)
}
const drawerOpen = ref(false)
const selectedRole = ref<Role | null>(null)
const deleteModalOpen = ref(false)
const roleToDelete = ref<Role | null>(null)
const deleting = ref(false)
// Charger la liste des roles
async function loadRoles() {
loading.value = true
try {
const data = await api.get<{ member: Role[] }>(
'/roles',
{},
{ toast: false },
)
roles.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedRole.value = null
drawerOpen.value = true
}
function openEditDrawer(role: Role) {
selectedRole.value = role
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedRole.value || selectedRole.value.isSystem) return
roleToDelete.value = selectedRole.value
deleteModalOpen.value = true
}
async function handleDelete() {
if (!roleToDelete.value) return
deleting.value = true
try {
await api.delete(`/roles/${roleToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.roles.toast.deleted'),
})
deleteModalOpen.value = false
roleToDelete.value = null
drawerOpen.value = false
await loadRoles()
} finally {
deleting.value = false
}
}
function onRoleSaved() {
loadRoles()
}
onMounted(() => {
loadRoles()
})
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.users.title') }}
</h1>
</div>
<!-- Table des utilisateurs -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="userItems"
:total-items="users.length"
:row-clickable="canManage"
:empty-message="t('admin.users.noUsers')"
@row-click="onRowClick"
>
<template #cell-admin="{ item }">
<span
v-if="item.admin"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
>
{{ t('admin.users.table.admin') }}
</span>
</template>
</MalioDataTable>
<!-- Drawer RBAC -->
<UserRbacDrawer
v-model="drawerOpen"
:user="selectedUser"
@saved="onUserSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
const columns = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
]
const userItems = computed(() =>
users.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
roles: user.roles.length,
directPermissions: user.directPermissions.length,
}))
)
async function loadUsers() {
loading.value = true
try {
const data = await api.get<{ member: UserListItem[] }>(
'/users',
{},
{ toast: false },
)
users.value = data.member
} finally {
loading.value = false
}
}
function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id)
}
function openDrawer(user: UserListItem) {
selectedUser.value = user
drawerOpen.value = true
}
function onRowClick(item: Record<string, unknown>) {
const user = getUserById(item.id as number)
if (user) openDrawer(user)
}
function onUserSaved() {
loadUsers()
}
onMounted(() => {
loadUsers()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,12 @@
"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",
"@malio/layer-ui": "^1.3.0",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -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"
}
}

View 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)
})
})

View 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 }
}

View File

@@ -0,0 +1,31 @@
export interface Permission {
id: number
code: string
label: string
module: string
orphan: boolean
}
export interface Role {
id: number
code: string
label: string
description: string | null
isSystem: boolean
permissions: (Permission | string)[]
}
export interface UserListItem {
id: number
username: string
isAdmin: boolean
roles: string[]
directPermissions: string[]
}
export interface EffectivePermission {
code: string
label: string
module: string
sources: string[]
}

View File

@@ -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
View 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)),
},
},
})

View File

@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate test-db-setup
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -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/
@@ -79,9 +83,23 @@ build-without-cache:
migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
# Cree et initialise la base de test utilisee par PHPUnit
# (le suffixe "_test" est applique automatiquement par Doctrine en APP_ENV=test)
# Ordre : fixtures -> sync-permissions, car fixtures:load purge la table permission
test-db-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
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 +108,8 @@ db-reset:
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists
$(MAKE) migration-migrate
$(MAKE) fixtures
$(MAKE) sync-permissions
$(MAKE) test-db-setup
# Restart la bdd
db-restart:
@@ -127,5 +147,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

View File

@@ -15,6 +15,7 @@
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" />
</php>
<testsuites>

View File

@@ -32,8 +32,8 @@ 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'],
];
}
}

View File

@@ -4,10 +4,31 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('ROLE_USER')",
),
new Get(
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('ROLE_USER')",
),
],
)]
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
@@ -18,18 +39,23 @@ class Permission
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['permission:read'])]
private string $code;
#[ORM\Column(length: 255)]
#[Groups(['permission:read'])]
private string $label;
#[ORM\Column(length: 100)]
#[Groups(['permission:read'])]
private string $module;
#[ORM\Column(options: ['default' => false])]
#[Groups(['permission:read'])]
private bool $orphan = false;
/**

View File

@@ -4,12 +4,25 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
@@ -18,27 +31,70 @@ use Doctrine\ORM\Mapping as ORM;
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
* peuvent etre supprimes.
*/
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['role:read']],
security: "is_granted('core.roles.view')",
),
new Get(
normalizationContext: ['groups' => ['role:read']],
security: "is_granted('core.roles.view')",
),
new Post(
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
security: "is_granted('core.roles.manage')",
processor: RoleProcessor::class,
),
new Patch(
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
security: "is_granted('core.roles.manage')",
processor: RoleProcessor::class,
),
new Delete(
security: "is_granted('core.roles.manage')",
processor: RoleProcessor::class,
),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')]
private string $code;
#[ORM\Column(length: 255)]
#[Groups(['role:read', 'role:write'])]
#[Assert\NotBlank]
private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['role:read', 'role:write'])]
private ?string $description = null;
// Volontairement exclu du groupe `role:write` : un client ne doit jamais
// pouvoir positionner ce flag via l'API. Seules les fixtures et migrations
// creent les roles systeme.
#[ORM\Column(name: 'is_system', options: ['default' => false])]
#[Groups(['role:read'])]
private bool $isSystem = false;
/** @var Collection<int, Permission> */
@@ -53,6 +109,7 @@ class Role
// projection cachee (ticket a ouvrir a ce moment-la).
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)
@@ -84,6 +141,12 @@ class Role
return $this->description;
}
// Le getter est annote directement car la convention Symfony PropertyInfo
// strip le prefixe `is` et exposerait le champ sous le nom `system`. On
// pose donc un SerializedName explicite pour garantir la sortie JSON-LD
// sous `isSystem`, nom attendu par les clients de l'API.
#[Groups(['role:read'])]
#[SerializedName('isSystem')]
public function isSystem(): bool
{
return $this->isSystem;
@@ -95,6 +158,23 @@ class Role
return $this->permissions;
}
/**
* Setter expose uniquement a la denormalisation API Platform pour
* permettre au RoleProcessor de detecter une tentative de modification
* du code (garde "code immuable"). Le code reste en pratique fige apres
* creation : le processor refuse toute modification via 400.
*
* @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes.
* Hors contexte API Platform, cette methode modifie silencieusement
* le code sans aucun garde.
*/
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
/**
* Met a jour le libelle affichable du role. Le code reste immuable pour
* garantir la stabilite des references cote fixtures et migrations.

View File

@@ -11,6 +11,8 @@ 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;
use DateTimeImmutable;
@@ -20,6 +22,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ApiResource(
operations: [
@@ -29,14 +32,24 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['me:read']],
),
new Get(
security: "is_granted('core.users.view')",
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
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 Delete(security: "is_granted('ROLE_ADMIN')"),
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',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
],
denormalizationContext: ['groups' => ['user:write']],
)]
@@ -47,7 +60,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'user:list'])]
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
@@ -55,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'])]
// 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;
/**
@@ -70,20 +86,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list'])]
private Collection $roles;
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
// La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
// collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
// on reexpose la cle JSON sous `roles` via SerializedName pour rester
// conforme au contrat API documente dans le ticket #344.
#[SerializedName('roles')]
private Collection $rbacRoles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $roles : garantie que
* Meme justification EAGER que pour $rbacRoles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list'])]
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
#[ORM\Column]
@@ -98,7 +119,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->roles = new ArrayCollection();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
@@ -131,10 +152,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
*
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
* sur un scalaire.
* Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
* ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
* la Collection peut ne pas etre hydratee. On se contente d'un calcul
* base sur un scalaire.
*
* @see getRbacRoles() pour la collection RBAC metier (exposee en JSON sous la cle "roles").
*
* @return list<string>
*/
@@ -149,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;
@@ -170,13 +197,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getRbacRoles(): Collection
{
return $this->roles;
return $this->rbacRoles;
}
public function addRbacRole(Role $role): static
{
if (!$this->roles->contains($role)) {
$this->roles->add($role);
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
return $this;
@@ -184,7 +211,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function removeRbacRole(Role $role): static
{
$this->roles->removeElement($role);
$this->rbacRoles->removeElement($role);
return $this;
}
@@ -225,11 +252,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*
* @return list<string>
*/
#[Groups(['me:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->roles as $role) {
foreach ($this->rbacRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,71 @@
<?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).
*
* TOCTOU accepte : la verification n'utilise pas de verrou pessimiste
* (SELECT ... FOR UPDATE). Deux demotions concurrentes pourraient donc
* passer le garde simultanement. Ce risque est accepte dans le contexte
* PME/CRM ou les operations d'administration sont rares et mono-operateur.
* Si la concurrence admin devient un enjeu, ajouter un verrou pessimiste
* sur countAdmins() ou une contrainte CHECK en base.
*
* @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();
}
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor applicatif pour l'entite Role.
*
* Choix d'implementation : une seule classe qui recoit en dependances les deux
* processors Doctrine decores (Persist et Remove) et branche l'un ou l'autre
* selon le type d'operation. Ce choix reste plus lisible que deux classes
* jumelees et reflete la symetrie des gardes metier (immuabilite du `code`
* cote ecriture, protection des roles systeme cote suppression).
*
* Gardes metier :
* - DELETE : delegue a Role::ensureDeletable() et traduit la
* SystemRoleDeletionException en AccessDeniedHttpException (403).
* - POST/PATCH : refuse toute modification du `code` (champ immuable apres
* creation), regle uniforme pour les roles systeme ET custom.
*
* @implements ProcessorInterface<Role, null|Role>
*/
final class RoleProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Role) {
// Ce processor est wire exclusivement sur les operations Role.
// Si on arrive ici avec autre chose, c'est une misconfiguration
// qu'il faut faire remonter fort.
throw new LogicException(sprintf(
'RoleProcessor attend une instance de %s, %s recu.',
Role::class,
get_debug_type($data),
));
}
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (SystemRoleDeletionException $e) {
// Traduction HTTP : le domaine reste pur, l'API renvoie 403.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
// Ecriture (POST/PATCH) : verifier l'immuabilite du `code`.
// L'UnitOfWork n'expose un etat d'origine que pour les entites deja
// managees (PATCH). Pour un POST (entite nouvelle), `getOriginalEntityData`
// retourne un tableau vide : aucune comparaison necessaire.
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
if (isset($originalData['code']) && $originalData['code'] !== $data->getCode()) {
throw new BadRequestHttpException("Le code d'un role est immuable apres creation.");
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,85 @@
<?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 Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor dedie a l'endpoint RBAC `PATCH /api/users/{id}/rbac`.
*
* Delegue la persistance au PersistProcessor Doctrine decore apres avoir
* applique les gardes metier propres aux changements de droits. Cet endpoint
* 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 (dans l'ordre d'execution) :
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
* 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>
*/
final class UserRbacProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
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
{
if (!$data instanceof User) {
// Ce processor est wire exclusivement sur l'operation user_rbac_patch
// qui cible User. Si on arrive ici avec autre chose, c'est une
// misconfiguration qu'il faut faire remonter fort.
throw new LogicException(sprintf(
'UserRbacProcessor attend une instance de %s, %s recu.',
User::class,
get_debug_type($data),
));
}
$currentUser = $this->security->getUser();
// 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();
// 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);
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -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()
;
}
}

View 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);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<object>
@@ -16,10 +17,10 @@ class SidebarProvider implements ProviderInterface
/** @var list<string> */
private readonly array $activeModuleIds;
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
private readonly array $sidebarConfig;
public function __construct()
public function __construct(private readonly Security $security)
{
$configDir = dirname(__DIR__, 5).'/config';
@@ -58,6 +59,18 @@ class SidebarProvider implements ProviderInterface
continue;
}
// Filtrage par permission RBAC : si l'item declare une permission
// requise et que l'utilisateur courant ne la possede pas, l'item
// est masque et sa route ajoutee aux routes desactivees.
$requiredPermission = $item['permission'] ?? null;
if (null !== $requiredPermission && !$this->security->isGranted($requiredPermission)) {
if (isset($item['to'])) {
$disabledRoutes[] = $item['to'];
}
continue;
}
$items[] = [
'label' => $item['label'],
'to' => $item['to'],

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
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.
*
* Mutualise :
* - `$alwaysBootKernel = true` : bascule le nouveau comportement API Platform 5
* et evite la deprecation emise a la creation du client de test.
* - `authenticatedClient()` : cree un client authentifie via `/login_check`
* (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
*/
abstract class AbstractApiTestCase extends ApiTestCase
{
// Bascule explicite sur le nouveau comportement API Platform 5 pour
// eviter la deprecation emise a la creation du client de test.
protected static ?bool $alwaysBootKernel = true;
/**
* Recupere l'EntityManager depuis le container courant. A utiliser a
* chaque appel : apres un createClient(), le kernel est reboote et tout
* EM precedemment capture est invalide.
*/
protected function getEm(): EntityManagerInterface
{
if (!self::$kernel) {
self::bootKernel();
}
return self::getContainer()->get('doctrine')->getManager();
}
/**
* Cree un client authentifie via /login_check. La configuration du projet
* pose le JWT dans un cookie HTTP-only `BEARER` (cf. lexik_jwt_authentication.yaml)
* et retire le token du body de reponse ; le client BrowserKit persiste
* automatiquement le cookie pour les requetes suivantes.
*/
protected function authenticatedClient(string $username, string $password): Client
{
$client = self::createClient();
$response = $client->request('POST', '/login_check', [
'headers' => ['Content-Type' => 'application/json'],
'json' => ['username' => $username, 'password' => $password],
]);
self::assertContains(
$response->getStatusCode(),
[200, 204],
'Login failed for '.$username.': '.$response->getStatusCode(),
);
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];
}
}

View 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();
}
}

View File

@@ -0,0 +1,208 @@
<?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;
/**
* Tests fonctionnels de l'exposition API Platform de l'entite Permission.
*
* Strategie de donnees : on cree directement quelques instances de Permission
* via l'EntityManager au setUp (choix le plus simple et le plus rapide, pas
* besoin de booter la commande app:sync-permissions). Les fixtures de test
* sont prefixees par "test." pour ne pas collisionner avec d'eventuelles
* permissions reelles et sont nettoyees en tearDown.
*
* @internal
*/
final class PermissionApiTest extends AbstractApiTestCase
{
private const TEST_CODE_PREFIX = 'test.';
protected function setUp(): void
{
parent::setUp();
// On boote le kernel une fois pour pouvoir seeder les fixtures.
// ATTENTION : ne pas stocker l'EntityManager dans une propriete,
// chaque createClient() dans les tests rebootera le kernel et
// invalidera tout EM capture ici (cf. $alwaysBootKernel = true).
self::bootKernel();
$em = $this->getEm();
// Nettoyage defensif au cas ou un run precedent aurait laisse des restes.
$this->cleanupTestPermissions();
// Donnees de test : deux permissions "core" dont une orpheline,
// plus une permission d'un autre module pour verifier le filtre.
$p1 = new Permission('test.core.users.view', 'View users (test)', 'core');
$p2 = new Permission('test.core.users.manage', 'Manage users (test)', 'core');
$p3 = new Permission('test.commercial.clients.view', 'View clients (test)', 'commercial');
$p2->markOrphan();
$em->persist($p1);
$em->persist($p2);
$em->persist($p3);
$em->flush();
$em->clear();
}
protected function tearDown(): void
{
$this->cleanupTestPermissions();
parent::tearDown();
}
public function testGetCollectionAsAdminReturns200(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// API Platform 4 emet du JSON-LD 1.1 avec un @context qui utilise un
// @vocab : les cles sortent donc non prefixees (`member`, `totalItems`)
// au lieu des anciennes `hydra:member` / `hydra:totalItems`.
self::assertArrayHasKey('member', $data);
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testCollectionFilterByModule(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['module' => 'core'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
foreach ($data['member'] as $item) {
self::assertSame('core', $item['module']);
}
// Doit contenir au moins nos deux permissions core de test.
$codes = array_column($data['member'], 'code');
self::assertContains('test.core.users.view', $codes);
self::assertContains('test.core.users.manage', $codes);
self::assertNotContains('test.commercial.clients.view', $codes);
}
public function testCollectionFilterByOrphanTrue(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'true'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
foreach ($data['member'] as $item) {
self::assertTrue($item['orphan']);
}
$codes = array_column($data['member'], 'code');
// La permission marquee orpheline dans setUp() doit remonter...
self::assertContains('test.core.users.manage', $codes);
// ...et celles non orphelines doivent etre exclues.
self::assertNotContains('test.core.users.view', $codes);
self::assertNotContains('test.commercial.clients.view', $codes);
}
public function testCollectionFilterByOrphanFalse(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'false'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
foreach ($data['member'] as $item) {
self::assertFalse($item['orphan']);
}
$codes = array_column($data['member'], 'code');
self::assertContains('test.core.users.view', $codes);
self::assertNotContains('test.core.users.manage', $codes);
}
public function testGetItemAsAdminReturnsAllReadFields(): void
{
/** @var null|Permission $permission */
$permission = $this->getEm()->getRepository(Permission::class)
->findOneBy(['code' => 'test.core.users.view'])
;
self::assertNotNull($permission);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions/'.$permission->getId());
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame($permission->getId(), $data['id']);
self::assertSame('test.core.users.view', $data['code']);
self::assertSame('View users (test)', $data['label']);
self::assertSame('core', $data['module']);
self::assertFalse($data['orphan']);
}
public function testPostIsMethodNotAllowed(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/permissions', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['code' => 'test.foo.bar.baz', 'label' => 'Foo', 'module' => 'foo'],
]);
self::assertResponseStatusCodeSame(405);
}
public function testUnauthenticatedReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/permissions');
self::assertResponseStatusCodeSame(401);
}
public function testStandardUserCanListPermissions(): void
{
// Le catalogue de permissions est accessible a tout utilisateur authentifie.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/permissions');
self::assertResponseIsSuccessful();
}
public function testStandardUserCanGetPermission(): 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::assertResponseIsSuccessful();
}
private function cleanupTestPermissions(): void
{
$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();
}
}

View File

@@ -0,0 +1,481 @@
<?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 App\Module\Core\Domain\Security\SystemRoles;
/**
* Tests fonctionnels de l'exposition API Platform de l'entite Role (CRUD nominal).
*
* Strategie :
* - Les roles systeme `admin` et `user` sont deja charges par les fixtures
* (cf. AppFixtures::ensureSystemRole). On ne les touche JAMAIS.
* - Les roles et permissions crees pour les tests ont le prefixe `test.` et
* sont purges en setUp + tearDown par DQL prefixe.
* - Les cas 403 sur role systeme et 400 sur modification de `code` sont
* reportes a la Task 3 (RoleProcessor) et ne sont PAS testes ici.
*
* @internal
*/
final class RoleApiTest extends AbstractApiTestCase
{
// Prefixe pour les roles de test : `test_` (underscore) parce que les
// codes de role doivent matcher `/^[a-z][a-z0-9_]*$/` (pas de point
// autorise, contrairement aux permissions).
private const TEST_ROLE_PREFIX = 'test_';
// Prefixe pour les permissions de test : `test.` (point) parce que les
// codes de permission doivent contenir au moins un `.` (convention
// module.resource.action validee dans le constructeur Permission).
private const TEST_PERMISSION_PREFIX = 'test.';
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$em = $this->getEm();
// Nettoyage defensif au cas ou un run precedent aurait laisse des restes.
$this->cleanupTestData();
// Permissions de test reutilisables (notamment pour le PATCH).
$p1 = new Permission('test.core.roles.view', 'View roles (test)', 'core');
$p2 = new Permission('test.core.roles.manage', 'Manage roles (test)', 'core');
$em->persist($p1);
$em->persist($p2);
// Role custom existant : utilise pour les GET / PATCH / DELETE.
$editor = new Role('test_editor', 'Editeur (test)', false, 'Role de test editeur');
$em->persist($editor);
// Deuxieme role custom : pour enrichir les collections.
$viewer = new Role('test_viewer', 'Visualisateur (test)', false);
$em->persist($viewer);
$em->flush();
$em->clear();
}
protected function tearDown(): void
{
$this->cleanupTestData();
parent::tearDown();
}
public function testPostCreatesCustomRoleAsAdmin(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/roles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'code' => 'test_new_editor',
'label' => 'Nouvel editeur',
'description' => 'Role de test',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertSame('test_new_editor', $data['code']);
self::assertSame('Nouvel editeur', $data['label']);
self::assertFalse($data['isSystem']);
// Verification cote base : le role existe et isSystem = false.
$persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_new_editor']);
self::assertNotNull($persisted);
self::assertFalse($persisted->isSystem());
}
public function testPostWithDuplicateCodeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/roles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
// `admin` est un role systeme charge par les fixtures.
'code' => SystemRoles::ADMIN_CODE,
'label' => 'Tentative de doublon',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testPostWithInvalidCodeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/roles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
// Majuscules interdites par la regex snake_case.
'code' => 'BadCode',
'label' => 'Code invalide',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testPostWithIsSystemTrueIgnoresItAndPersistsFalse(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/roles', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'code' => 'test_sneaky',
'label' => 'Tentative systeme',
'isSystem' => true,
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertFalse($data['isSystem']);
$persisted = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_sneaky']);
self::assertNotNull($persisted);
self::assertFalse($persisted->isSystem());
}
public function testGetCollectionAsAdminReturnsRoles(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('member', $data);
// Au moins admin systeme + user systeme + test_editor + test_viewer.
self::assertGreaterThanOrEqual(4, $data['totalItems']);
$codes = array_column($data['member'], 'code');
self::assertContains('test_editor', $codes);
}
public function testGetCollectionFilterByIsSystemTrue(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles', [
'query' => ['isSystem' => 'true'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
foreach ($data['member'] as $item) {
self::assertTrue($item['isSystem']);
}
$codes = array_column($data['member'], 'code');
self::assertNotContains('test_editor', $codes);
self::assertNotContains('test_viewer', $codes);
}
public function testGetItemReturnsAllReadFields(): void
{
$role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($role);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles/'.$role->getId());
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('test_editor', $data['code']);
self::assertSame('Editeur (test)', $data['label']);
self::assertSame('Role de test editeur', $data['description']);
self::assertFalse($data['isSystem']);
self::assertArrayHasKey('permissions', $data);
self::assertIsArray($data['permissions']);
}
public function testPatchCustomRoleUpdatesLabelAndAddsPermission(): void
{
$em = $this->getEm();
$role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($role);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.roles.view']);
self::assertNotNull($permission);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/roles/'.$role->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'label' => 'Editeur modifie',
'permissions' => ['/api/permissions/'.$permission->getId()],
],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('Editeur modifie', $data['label']);
self::assertCount(1, $data['permissions']);
// Verification cote base.
$em->clear();
/** @var Role $reloaded */
$reloaded = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertSame('Editeur modifie', $reloaded->getLabel());
self::assertCount(1, $reloaded->getPermissions());
}
public function testDeleteCustomRoleReturns204(): void
{
$role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_viewer']);
self::assertNotNull($role);
$id = $role->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/roles/'.$id);
self::assertResponseStatusCodeSame(204);
$em = $this->getEm();
$em->clear();
self::assertNull($em->getRepository(Role::class)->find($id));
}
public function testDeleteCustomRoleAttachedToUserDoesNotDeleteUser(): void
{
// Scenario spec #344 sections 7 & 11 : supprimer un role custom rattache
// a un user doit laisser le user en base (la FK user_role est nettoyee
// par ON DELETE CASCADE, mais jamais le user lui-meme).
$em = $this->getEm();
// Creer un user de test dedie et lui rattacher le role custom `test_editor`.
$testUser = new User();
$testUser->setUsername('test_cascade_user');
// Le hashage du password est hors scope du test mais la colonne est NOT NULL.
$testUser->setPassword('not-hashed-ok-for-test');
/** @var Role $editor */
$editor = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($editor);
$testUser->addRbacRole($editor);
$em->persist($testUser);
$em->flush();
$userId = $testUser->getId();
$editorId = $editor->getId();
$em->clear();
// DELETE du role editor via l'API.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/roles/'.$editorId);
self::assertResponseStatusCodeSame(204);
// Verification : l'user existe toujours et sa collection de roles est vide.
$em = $this->getEm();
/** @var null|User $refreshed */
$refreshed = $em->getRepository(User::class)->find($userId);
self::assertNotNull($refreshed, 'L\'user ne doit PAS etre supprime par le cascade.');
self::assertCount(0, $refreshed->getRbacRoles(), 'La relation user_role doit etre nettoyee par le cascade.');
// Cleanup explicite : cleanupTestData() ne purge pas les users.
$em->remove($refreshed);
$em->flush();
}
public function testDeleteSystemRoleReturns403(): void
{
$role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
self::assertNotNull($role);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/roles/'.$role->getId());
self::assertResponseStatusCodeSame(403);
// Le role systeme doit toujours exister.
$em = $this->getEm();
$em->clear();
self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]));
}
public function testPatchSystemRoleLabelReturns200(): void
{
$em = $this->getEm();
$role = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
self::assertNotNull($role);
$originalLabel = $role->getLabel();
$roleId = $role->getId();
$client = $this->authenticatedClient('admin', 'admin');
try {
$response = $client->request('PATCH', '/api/roles/'.$roleId, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['label' => 'Administrateur (modifie test)'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('Administrateur (modifie test)', $data['label']);
self::assertSame(SystemRoles::ADMIN_CODE, $data['code']);
self::assertTrue($data['isSystem']);
} finally {
// Restauration defensive du label original pour ne pas polluer
// les tests suivants (les fixtures systeme sont partagees).
$em = $this->getEm();
/** @var null|Role $reloaded */
$reloaded = $em->getRepository(Role::class)->findOneBy(['code' => SystemRoles::ADMIN_CODE]);
if (null !== $reloaded && $reloaded->getLabel() !== $originalLabel) {
$reloaded->setLabel($originalLabel);
$em->flush();
}
}
}
public function testPatchRoleCodeChangeReturns400(): void
{
$role = $this->getEm()->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($role);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/roles/'.$role->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['code' => 'test_editor_renamed'],
]);
self::assertResponseStatusCodeSame(400);
// Verification cote base : le code d'origine n'a pas bouge.
$em = $this->getEm();
$em->clear();
self::assertNotNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']));
self::assertNull($em->getRepository(Role::class)->findOneBy(['code' => 'test_editor_renamed']));
}
public function testUnauthenticatedGetCollectionReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/roles');
self::assertResponseStatusCodeSame(401);
}
public function testNonAdminGetCollectionReturns403(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/roles');
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
* fixtures.
*/
private function cleanupTestData(): void
{
$em = $this->getEm();
// Le cascade FK de la migration #343 (ON DELETE CASCADE sur
// role_permission.role_id et permission_id) nettoie automatiquement
// role_permission lors du DELETE SQL emis par Doctrine, meme via DQL
// bulk delete : le cascade est applique au niveau FK par PostgreSQL,
// pas par l'Unit of Work Doctrine. Verifie par comptage avant/apres
// runs successifs de la suite (stable a la ligne de base systeme).
// Purge defensive des users de test crees par certains scenarios
// (ex: testDeleteCustomRoleAttachedToUserDoesNotDeleteUser). Doit etre
// fait AVANT la suppression des roles pour que le cascade FK ne soit
// pas sollicite en ordre inverse.
$em->createQuery(
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
)->setParameter('prefix', self::TEST_ROLE_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();
}
}

View 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();
}
}

View File

@@ -0,0 +1,305 @@
<?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 RBAC dedie `PATCH /api/users/{id}/rbac`.
*
* Strategie de donnees :
* - On cree des users, roles et permissions prefixes `test_` / `test.`
* en setUp et on les purge en tearDown.
* - On ne touche JAMAIS aux fixtures (admin / alice / bob). Les cas qui
* ont besoin d'un user standard authentifie s'appuient sur alice sans
* modification d'etat.
* - Les users de test incluent un admin dedie pour le cas d'auto-suicide,
* pour ne pas risquer de corrompre l'admin fixture.
*
* @internal
*/
final class UserRbacApiTest extends AbstractApiTestCase
{
private const TEST_USER_PREFIX = 'test_';
private const TEST_ROLE_PREFIX = 'test_';
private const TEST_PERMISSION_PREFIX = 'test.';
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$em = $this->getEm();
$this->cleanupTestData();
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin).
$target = new User();
$target->setUsername('test_target');
$target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret'));
$em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).
$selfAdmin = new User();
$selfAdmin->setUsername('test_self_admin');
$selfAdmin->setIsAdmin(true);
$selfAdmin->setPassword($hasher->hashPassword($selfAdmin, 'secret'));
$em->persist($selfAdmin);
// Role custom pour tester le remplacement de la collection roles.
$role = new Role('test_editor', 'Editeur (test)', false);
$em->persist($role);
// Permission custom pour tester directPermissions.
$permission = new Permission('test.core.users.view', 'View users (test)', 'core');
$em->persist($permission);
$em->flush();
$em->clear();
}
protected function tearDown(): void
{
$this->cleanupTestData();
parent::tearDown();
}
public function testPatchRbacPromotesUserToAdmin(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertTrue($reloaded->isAdmin());
}
public function testPatchRbacReplacesRolesCollection(): void
{
$em = $this->getEm();
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
$role = $em->getRepository(Role::class)->findOneBy(['code' => 'test_editor']);
self::assertNotNull($target);
self::assertNotNull($role);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['roles' => ['/api/roles/'.$role->getId()]],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertCount(1, $reloaded->getRbacRoles());
self::assertSame('test_editor', $reloaded->getRbacRoles()->first()->getCode());
}
public function testPatchRbacReplacesDirectPermissionsCollection(): void
{
$em = $this->getEm();
$target = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'test.core.users.view']);
self::assertNotNull($target);
self::assertNotNull($permission);
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['directPermissions' => ['/api/permissions/'.$permission->getId()]],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertCount(1, $reloaded->getDirectPermissions());
self::assertSame('test.core.users.view', $reloaded->getDirectPermissions()->first()->getCode());
}
public function testPatchRbacAsStandardUserReturns403(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testPatchRbacUnauthenticatedReturns401(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$client = self::createClient();
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
self::assertResponseStatusCodeSame(401);
}
public function testPatchRbacIgnoresUsernameField(): void
{
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$targetId = $target->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'username' => 'test_target_renamed',
'isAdmin' => true,
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($targetId);
// `username` n'est pas dans `user:rbac:write` : ignore en denormalization.
self::assertSame('test_target', $reloaded->getUsername());
// `isAdmin` est bien applique.
self::assertTrue($reloaded->isAdmin());
}
public function testPatchProfileEndpointDoesNotModifyIsAdmin(): void
{
// Confirme la decision 0fc4e16 : `isAdmin` n'est plus dans `user:write`,
// donc `PATCH /api/users/{id}` sans `/rbac` ne peut plus promouvoir.
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
self::assertNotNull($target);
$targetId = $target->getId();
self::assertFalse($target->isAdmin());
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$targetId, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => true],
]);
// Peu importe le code : le champ ne doit tout simplement pas bouger.
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($targetId);
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
// corrompre l'admin fixture en cas de bug.
$em = $this->getEm();
$selfAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'test_self_admin']);
self::assertNotNull($selfAdmin);
$selfAdminId = $selfAdmin->getId();
$client = $this->authenticatedClient('test_self_admin', 'secret');
$client->request('PATCH', '/api/users/'.$selfAdminId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isAdmin' => false],
]);
self::assertResponseStatusCodeSame(400);
$em = $this->getEm();
$em->clear();
/** @var User $reloaded */
$reloaded = $em->getRepository(User::class)->find($selfAdminId);
self::assertTrue($reloaded->isAdmin());
}
private function cleanupTestData(): void
{
$em = $this->getEm();
// Ordre important : delier les collections avant de supprimer les
// entites referencees pour que les FK cascade s'appliquent via le
// schema PostgreSQL.
$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();
}
}

View 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);
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use LogicException;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use stdClass;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Tests unitaires du RoleProcessor : couvre les gardes metier
* (immuabilite du code, refus de suppression des roles systeme) et la
* delegation aux processors Doctrine decores.
*
* @internal
*/
#[AllowMockObjectsWithoutExpectations]
final class RoleProcessorTest extends TestCase
{
private MockObject&ProcessorInterface $persistProcessor;
private MockObject&ProcessorInterface $removeProcessor;
private EntityManagerInterface&MockObject $entityManager;
private MockObject&UnitOfWork $unitOfWork;
private RoleProcessor $processor;
protected function setUp(): void
{
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
$this->removeProcessor = $this->createMock(ProcessorInterface::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->unitOfWork = $this->createMock(UnitOfWork::class);
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
$this->processor = new RoleProcessor(
$this->persistProcessor,
$this->removeProcessor,
$this->entityManager,
);
}
public function testDeleteCustomRoleDelegatesToRemoveProcessor(): void
{
$role = new Role('editor', 'Editor', false);
$this->removeProcessor
->expects(self::once())
->method('process')
->with($role)
->willReturn(null)
;
$this->persistProcessor->expects(self::never())->method('process');
$result = $this->processor->process($role, new Delete());
self::assertNull($result);
}
public function testDeleteSystemRoleThrowsAccessDeniedHttpException(): void
{
$role = new Role('admin', 'Admin', true);
$this->removeProcessor->expects(self::never())->method('process');
$this->persistProcessor->expects(self::never())->method('process');
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Le role systeme "admin" ne peut pas etre supprime.');
$this->processor->process($role, new Delete());
}
public function testPostCreatesCustomRoleDelegatesToPersistProcessor(): void
{
$role = new Role('editor', 'Editor', false);
// Entite nouvelle : l'UnitOfWork n'a pas d'etat d'origine.
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($role)
->willReturn([])
;
$this->persistProcessor
->expects(self::once())
->method('process')
->with($role)
->willReturn($role)
;
$this->removeProcessor->expects(self::never())->method('process');
$result = $this->processor->process($role, new Post());
self::assertSame($role, $result);
}
public function testPatchWithChangedCodeThrowsBadRequestHttpException(): void
{
// L'entite arrive avec le nouveau code deja applique par le denormalizer.
$role = new Role('editor_renamed', 'Editor', false);
$this->setRoleId($role, 42);
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($role)
->willReturn([
'id' => 42,
'code' => 'editor',
'label' => 'Editor',
'isSystem' => false,
])
;
$this->persistProcessor->expects(self::never())->method('process');
$this->removeProcessor->expects(self::never())->method('process');
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage("Le code d'un role est immuable apres creation.");
$this->processor->process($role, new Patch());
}
public function testPatchWithUnchangedCodeDelegatesToPersistProcessor(): void
{
$role = new Role('editor', 'Editor modifie', false, 'desc');
$this->setRoleId($role, 42);
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($role)
->willReturn([
'id' => 42,
'code' => 'editor',
'label' => 'Editor',
'isSystem' => false,
])
;
$this->persistProcessor
->expects(self::once())
->method('process')
->with($role)
->willReturn($role)
;
$this->removeProcessor->expects(self::never())->method('process');
$result = $this->processor->process($role, new Patch());
self::assertSame($role, $result);
}
public function testPatchSystemRoleLabelDelegatesToPersistProcessor(): void
{
// Regle uniforme : un role systeme peut voir son label modifie tant
// que son code reste inchange. Seul le DELETE est bloque.
$role = new Role('admin', 'Administrateur', true);
$this->setRoleId($role, 1);
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($role)
->willReturn([
'id' => 1,
'code' => 'admin',
'label' => 'Admin',
'isSystem' => true,
])
;
$this->persistProcessor
->expects(self::once())
->method('process')
->with($role)
->willReturn($role)
;
$this->removeProcessor->expects(self::never())->method('process');
$result = $this->processor->process($role, new Patch());
self::assertSame($role, $result);
}
public function testProcessNonRoleDataThrowsLogicException(): void
{
// Garde-fou contre une misconfiguration : ce processor est wire
// exclusivement sur les operations Role.
$this->persistProcessor->expects(self::never())->method('process');
$this->removeProcessor->expects(self::never())->method('process');
$this->expectException(LogicException::class);
$this->expectExceptionMessage('RoleProcessor attend une instance de');
$this->processor->process(new stdClass(), new Patch());
}
/**
* Positionne l'id d'un Role via reflection pour simuler une entite deja
* persistee (les mocks d'UnitOfWork n'alimentent pas l'id tout seul).
*/
private function setRoleId(Role $role, int $id): void
{
$refl = new ReflectionClass($role);
$prop = $refl->getProperty('id');
$prop->setAccessible(true);
$prop->setValue($role, $id);
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Patch;
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;
use LogicException;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* 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
*/
#[AllowMockObjectsWithoutExpectations]
final class UserRbacProcessorTest extends TestCase
{
private MockObject&ProcessorInterface $persistProcessor;
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->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
$this->processor = new UserRbacProcessor(
$this->persistProcessor,
$this->entityManager,
$this->security,
$this->adminHeadcountGuard,
);
}
public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
{
$target = $this->buildUser(42, 'alice', true);
$currentAdmin = $this->buildUser(1, 'admin', true);
$this->security->method('getUser')->willReturn($currentAdmin);
// 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())
->method('process')
->with($target)
->willReturn($target)
;
$result = $this->processor->process($target, new Patch());
self::assertSame($target, $result);
}
public function testPatchUpdatesRolesCollectionDelegatesToPersistProcessor(): void
{
$target = $this->buildUser(42, 'alice', false);
$target->addRbacRole(new Role('editor', 'Editor', false));
$currentAdmin = $this->buildUser(1, 'admin', true);
$this->security->method('getUser')->willReturn($currentAdmin);
$this->persistProcessor
->expects(self::once())
->method('process')
->with($target)
->willReturn($target)
;
$result = $this->processor->process($target, new Patch());
self::assertSame($target, $result);
self::assertCount(1, $result->getRbacRoles());
}
public function testPatchUpdatesDirectPermissionsCollectionDelegatesToPersistProcessor(): void
{
$target = $this->buildUser(42, 'alice', false);
$target->addDirectPermission(new Permission('core.users.view', 'View', 'core'));
$currentAdmin = $this->buildUser(1, 'admin', true);
$this->security->method('getUser')->willReturn($currentAdmin);
$this->persistProcessor
->expects(self::once())
->method('process')
->with($target)
->willReturn($target)
;
$result = $this->processor->process($target, new Patch());
self::assertSame($target, $result);
self::assertCount(1, $result->getDirectPermissions());
}
public function testPatchSelfRemovingAdminThrowsBadRequestHttpException(): void
{
// Meme identifiant : l'user courant PATCH sa propre ressource.
$self = $this->buildUser(1, 'admin', false);
$this->security->method('getUser')->willReturn($self);
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($self)
->willReturn([
'id' => 1,
'username' => 'admin',
'isAdmin' => true,
])
;
$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());
}
public function testPatchAdminDemotingAnotherUserIsAllowed(): void
{
// 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);
// 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())
->method('process')
->with($target)
->willReturn($target)
;
$result = $this->processor->process($target, new Patch());
self::assertSame($target, $result);
}
public function testPatchSelfKeepingAdminIsAllowed(): void
{
// L'user courant se PATCH lui-meme mais garde isAdmin = true :
// aucun auto-suicide, on delegue au PersistProcessor.
$self = $this->buildUser(1, 'admin', true);
$this->security->method('getUser')->willReturn($self);
$this->unitOfWork
->expects(self::once())
->method('getOriginalEntityData')
->with($self)
->willReturn([
'id' => 1,
'username' => 'admin',
'isAdmin' => true,
])
;
$this->persistProcessor
->expects(self::once())
->method('process')
->with($self)
->willReturn($self)
;
$result = $this->processor->process($self, new Patch());
self::assertSame($self, $result);
}
public function testProcessNonUserDataThrowsLogicException(): void
{
// Garde-fou contre une misconfiguration : ce processor est wire
// exclusivement sur l'operation user_rbac_patch (cible User).
$this->persistProcessor->expects(self::never())->method('process');
$this->expectException(LogicException::class);
$this->expectExceptionMessage('UserRbacProcessor attend une instance de');
$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).
*/
private function buildUser(int $id, string $username, bool $isAdmin): User
{
$user = new User();
$user->setUsername($username);
$user->setIsAdmin($isAdmin);
$refl = new ReflectionClass($user);
$prop = $refl->getProperty('id');
$prop->setAccessible(true);
$prop->setValue($user, $id);
return $user;
}
}

View File

@@ -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;
}
}