Compare commits

..

15 Commits

Author SHA1 Message Date
gitea-actions
c061a5f2e7 chore: bump version to v0.1.30
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 20s
2026-04-15 08:31:11 +00:00
Matthieu
46fa7d17ae chore(core) : merge RBAC ticket #343 + fix user:write sensibles (PR #2)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-15 10:30:59 +02:00
Matthieu
0fc4e1651b fix(core) : retire user:write des champs RBAC sensibles du User
isAdmin, roles et directPermissions ne doivent pas etre modifiables via
PATCH /api/users/{id}. L exposition en ecriture sera traitee par un
processor dedie dans le ticket #344 (spec section 2 OUT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:15:43 +02:00
Matthieu
d8bda517f9 docs : ajoute note delegation Codex pour taches mecaniques 2026-04-15 08:12:17 +02:00
Matthieu
7ccc913862 docs : exception CLAUDE.md pour les migrations multi-namespace
Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de
version timestamp avec plusieurs migrations_paths) et la regle
provisoire : migrations d'init au namespace racine, namespace
modulaire reserve aux migrations applicatives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:25:26 +02:00
Matthieu
eb0b49a7ef fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine
Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine
Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations
dans l'ordre (namespace, version) et non (version, namespace). Le
Version20260414150034 sous 'App\Module\Core\...' passait donc avant
Version20260407095546 sous 'DoctrineMigrations', provoquant un
"relation user does not exist".

Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations).
Le chemin modulaire reste configure pour les futurs modules, mais
la migration RBAC d'initialisation vit a la racine pour que
'make db-reset' fonctionne en one-shot.

Smoke test end-to-end valide :
- db-reset + fixtures : admin (is_admin=t, role admin), alice/bob
  (is_admin=f, role user)
- app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run
- User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER']
  pour alice/bob
- User::getEffectivePermissions() : union triee des permissions via roles

Ticket #343 - 7/7 : smoke test end-to-end OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:21:43 +02:00
Matthieu
0a496f34e0 fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes
ensureSystemRole() recopie desormais la description depuis la migration
RBAC pour que les chemins prod (migration) et dev (fixtures) produisent
un etat identique.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:15:23 +02:00
Matthieu
aafe08b6ad feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme
- AppFixtures : rattachement des users aux entites Role via
  RoleRepositoryInterface. Re-seed idempotent des roles systeme dans
  ensureSystemRole() pour compenser le purger Doctrine qui vide la table
  role avant load(), afin que "make db-reset && make fixtures" reste un
  workflow one-shot.
- CreateUserCommand : flag --admin attache au role systeme admin + is_admin,
  sinon au role user. Gestion d'erreur explicite si les roles systeme sont
  absents (FAILURE + message pointant vers la migration).
- CreateUserCommand devient final, descriptions traduites en francais.

Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:12:09 +02:00
Matthieu
d68aa0456a feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles
- Nouvelles tables permission, role, role_permission, user_role, user_permission
- Ajout user.is_admin (BOOLEAN, default false)
- Seed des roles systeme admin et user via SQL brut (autonome, pas besoin
  de fixtures pour cette etape)
- Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis
  rattachement user_role selon admin/user
- Drop user.roles en dernier (apres la migration de donnees)
- down() recree la colonne roles et la rehydrate depuis is_admin

Ticket #343 - 5/7 : persistance + migration donnees safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:02:26 +02:00
Matthieu
3b1f18b0e0 feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand
- CoreModule declare 4 permissions initiales (users.view/manage, roles.manage,
  permissions.view)
- Nouvelle commande app:sync-permissions :
  * scan des *Module::permissions() via config/modules.php
  * validation stricte : cles [code, label], prefixe module, non-vides
  * upsert transactionnel non-destructif
  * revival des permissions orphelines qui reapparaissent
  * marquage orphan pour les permissions disparues du code
  * un seul flush() final (evite le flush-par-save de la repo save())

Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:50 +02:00
Matthieu
7aa32b1972 feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes)
- Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5)
- Ajout is_admin bool (seul levier de bypass RBAC via getRoles())
- Ajout ManyToMany User-Role (EAGER, table user_role)
- Ajout ManyToMany User-Permission directes (EAGER, table user_permission)
- getEffectivePermissions() : union dedupliquee triee, utilisee par le
  futur PermissionVoter (#345)
- getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony
- Tests unitaires couvrant derivation getRoles, union, deduplication, tri

Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel.
Fetch EAGER documente : evite le lazy-load au refresh JWT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:48:49 +02:00
Matthieu
3b34d00872 feat(core) : RBAC Task 2 - repositories Permission et Role
- PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync
  command et le futur PermissionVoter)
- RoleRepositoryInterface avec findByCode
- Implementations Doctrine alignees sur DoctrineUserRepository
- Alias DI dans config/services.yaml
- Rebranchement de repositoryClass sur les entites Permission et Role

Ticket #343 - 2/7 : couche persistence RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:40:44 +02:00
Matthieu
0fc0b57e37 refactor(core) : RBAC Task 1 - polish apres revue qualite
- Permission : guards constructeur (code/label/module non vides, code avec point)
- Permission::revive() reutilise updateMetadata() pour eviter la duplication
- Suppression de SystemRolesTest (tautologique, ne capture aucun comportement)
- Role::permissions : commentaire explicite sur la raison du fetch EAGER
- Alignement des types de retour sur static (style User.php)
- Nouveau test Role::addPermission avec permissions distinctes

Ticket #343 - Task 1 polish (revue qualite).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:53 +02:00
Matthieu
f0ea9201f5 feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite
- Entite Permission avec methodes markOrphan/revive/updateMetadata
- Entite Role avec addPermission/removePermission/ensureDeletable
- Constantes SystemRoles (codes admin/user partages)
- Exception SystemRoleDeletionException pour la garde de suppression
- Tests unitaires couvrant le comportement domaine (pas de BDD)

Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence).
Les entites ne portent pas encore repositoryClass (ajoute en Task 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:30:15 +02:00
Matthieu
e3025bf2c9 docs(rbac) : plan et spec ticket #343 + conventions permissions
- Spec detaillee des fondations RBAC backend (entites Role/Permission, sync
  command, migration, fixtures, tests) dans docs/rbac/ticket-343-spec.md
- Ajout CLAUDE.md des regles projet : commentaires francais (PHP + TS/Vue)
  et convention de nommage des permissions module.resource[.sub].action

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:26:49 +02:00
49 changed files with 362 additions and 7911 deletions

View File

@@ -23,6 +23,7 @@
"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.*",
@@ -89,8 +90,6 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
"phpunit/phpunit": "^13.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": "75f8e672f2a401290886fbcf01befd3f",
"content-hash": "bfd26e903d79f710cfe95452c05f2a25",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -4988,6 +4988,180 @@
],
"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",
@@ -10844,322 +11018,6 @@
],
"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,14 +1,6 @@
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,8 +8,6 @@ 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.
@@ -34,20 +32,6 @@ 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.31'
app.version: '0.1.30'

View File

@@ -1,275 +0,0 @@
# 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

@@ -1,574 +0,0 @@
# 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,10 +22,6 @@
"commercial": {
"section": "Commercial",
"suppliers": "Répertoire fournisseurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs"
}
},
"dashboard": {
@@ -60,65 +56,5 @@
"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

@@ -1,68 +0,0 @@
<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

@@ -1,66 +0,0 @@
<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

@@ -1,79 +0,0 @@
<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

@@ -1,224 +0,0 @@
<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

@@ -1,259 +0,0 @@
<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

@@ -1,161 +0,0 @@
<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

@@ -1,107 +0,0 @@
<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,12 +10,10 @@
"postinstall": "nuxt prepare",
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest"
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@malio/layer-ui": "^1.3.0",
"@malio/layer-ui": "^1.2.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -30,11 +28,8 @@
"@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

@@ -1,65 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,31 +0,0 @@
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,8 +2,4 @@ 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[]
}

View File

@@ -1,15 +0,0 @@
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 test-db-setup
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
# 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,10 +59,6 @@ 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/
@@ -83,23 +79,9 @@ 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
@@ -108,8 +90,6 @@ 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:
@@ -147,8 +127,5 @@ 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,7 +15,6 @@
<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,31 +4,10 @@ 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'])]
@@ -39,23 +18,18 @@ 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,25 +4,12 @@ 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.
@@ -31,70 +18,27 @@ use Symfony\Component\Validator\Constraints as Assert;
* "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> */
@@ -109,7 +53,6 @@ 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)
@@ -141,12 +84,6 @@ 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;
@@ -158,23 +95,6 @@ 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,8 +11,6 @@ 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;
@@ -22,7 +20,6 @@ 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: [
@@ -32,24 +29,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
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('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),
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')"),
],
denormalizationContext: ['groups' => ['user:write']],
)]
@@ -60,7 +47,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
#[Groups(['me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
@@ -68,10 +55,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?string $username = null;
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
// 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'])]
#[Groups(['me:read', 'user:list'])]
private bool $isAdmin = false;
/**
@@ -86,25 +70,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[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;
#[Groups(['me:read', 'user:list'])]
private Collection $roles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $rbacRoles : garantie que
* Meme justification EAGER que pour $roles : 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', 'user:rbac:write', 'user:rbac:read'])]
#[Groups(['me:read', 'user:list'])]
private Collection $directPermissions;
#[ORM\Column]
@@ -119,7 +98,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->roles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
@@ -152,12 +131,10 @@ 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->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").
* 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.
*
* @return list<string>
*/
@@ -172,10 +149,6 @@ 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;
@@ -197,13 +170,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
return $this->roles;
}
public function addRbacRole(Role $role): static
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
if (!$this->roles->contains($role)) {
$this->roles->add($role);
}
return $this;
@@ -211,7 +184,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function removeRbacRole(Role $role): static
{
$this->rbacRoles->removeElement($role);
$this->roles->removeElement($role);
return $this;
}
@@ -252,12 +225,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*
* @return list<string>
*/
#[Groups(['me:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles as $role) {
foreach ($this->roles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}

View File

@@ -1,26 +0,0 @@
<?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,12 +13,4 @@ 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

@@ -1,71 +0,0 @@
<?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

@@ -1,31 +0,0 @@
<?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

@@ -1,81 +0,0 @@
<?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

@@ -1,62 +0,0 @@
<?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

@@ -1,85 +0,0 @@
<?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,20 +34,4 @@ 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

@@ -1,66 +0,0 @@
<?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,7 +7,6 @@ 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>
@@ -17,10 +16,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, permission?: string}>}> */
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
private readonly array $sidebarConfig;
public function __construct(private readonly Security $security)
public function __construct()
{
$configDir = dirname(__DIR__, 5).'/config';
@@ -59,18 +58,6 @@ 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

@@ -1,133 +0,0 @@
<?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

@@ -1,169 +0,0 @@
<?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

@@ -1,208 +0,0 @@
<?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

@@ -1,481 +0,0 @@
<?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

@@ -1,195 +0,0 @@
<?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

@@ -1,305 +0,0 @@
<?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

@@ -1,127 +0,0 @@
<?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

@@ -1,228 +0,0 @@
<?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

@@ -1,130 +0,0 @@
<?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

@@ -1,403 +0,0 @@
<?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

@@ -1,221 +0,0 @@
<?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;
}
}