Compare commits

..

1 Commits

Author SHA1 Message Date
Matthieu 54fe48993f fix(rbac) : referentiels /categories et /sites lisibles par les roles metier (ERP-102)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m50s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
Les roles metier (bureau / compta / commerciale) prenaient un 403 sur
GET /api/categories et GET /api/sites : la security des GetCollection/Get
exigeait catalog.categories.view / sites.view, permissions reservees a
l'administration du Catalogue et des Sites. Or ces referentiels sont
transverses (selects de creation/filtre client) : creation de client
cassee et filtres vides pour ces roles.

Correctif back (Option C — permission de lecture-referentiel dediee) :
- Nouvelles permissions catalog.categories.read_ref et sites.read_ref,
  distinctes de .view (pas d'item sidebar admin) et de .manage. Chaque
  permission appartient a son propre module -> aucun couplage inter-module
  (regle ABSOLUE n°1) et reutilisable tel quel par M2 Fournisseurs.
- Security lecture (liste + item) elargie : view OR read_ref sur Category
  et Site.
- Matrice RBAC § 2.7 (RbacSeeder) : read_ref attache a bureau / compta /
  commerciale. Usine reste sans acces.

Durcissement front (resilience, requis dans tous les cas) :
- useClientReferentials.loadCommon passe de Promise.all a Promise.allSettled
  avec affectation isolee par referentiel : l'echec d'un endpoint ne vide
  que SON select, plus la totalite du formulaire.

Tests :
- ClientRBACMatrixTest : les roles metier listent /categories et /sites (200),
  usine reste a 403.
- SitesModuleTest : set de permissions porte a 4 codes.
- useClientReferentials.spec : resilience d'un referentiel en echec.

Miroirs E2E (personas.ts / SeedE2ECommand) non touches : read_ref n'ajoute
aucun lien sidebar, le persona user-full lit deja via .view, et aucun
persona ne modelise un role metier seul ; pas de nouveau test E2E (regle n°7).
2026-06-03 14:13:45 +02:00
3 changed files with 2 additions and 33 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.73' app.version: '0.1.72'
@@ -30,8 +30,6 @@ use function sprintf;
* - resource != Site::class → no-op (les autres resources sont * - resource != Site::class → no-op (les autres resources sont
* gerees par SiteScopedQueryExtension) ; * gerees par SiteScopedQueryExtension) ;
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ; * - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
* - is_granted('sites.read_ref') → pas de filtre (lecture-referentiel
* transverse complet, ERP-102) ;
* - user non authentifie → no-op (API Platform renvoie 401 avant) ; * - user non authentifie → no-op (API Platform renvoie 401 avant) ;
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ; * - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
* - cas normal → WHERE site.id IN (:allowedSites). * - cas normal → WHERE site.id IN (:allowedSites).
@@ -86,16 +84,6 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt
return; return;
} }
// 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne
// acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers).
// Sans ce bypass, le cloisonnement par site rattache reduirait le select
// aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le
// referentiel ne serait plus "transverse". `read_ref` est une lecture seule :
// il ouvre la visibilite sans permettre la moindre ecriture.
if ($this->security->isGranted('sites.read_ref')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont). // 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser(); $user = $this->security->getUser();
if (!$user instanceof User) { if (!$user instanceof User) {
@@ -6,7 +6,6 @@ namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client; use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures; use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\NullOutput;
@@ -282,32 +281,14 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter // / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
// la permission d'administration `.view`. Usine, sans aucune permission, // la permission d'administration `.view`. Usine, sans aucune permission,
// reste interdit. // reste interdit.
// Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par
// site rattache (SiteCollectionScopedExtension) est neutralise par
// `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un
// seul site (Chatellerault) alors que la base en compte plusieurs : on
// verifie donc que le role voit la TOTALITE du referentiel, pas son seul
// site rattache. Sans le bypass de scope, totalItems vaudrait 1.
$totalSites = $this->getEm()->getRepository(Site::class)->count([]);
self::assertGreaterThan(
1,
$totalSites,
'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.',
);
foreach (['bureau', 'compta', 'commerciale'] as $role) { foreach (['bureau', 'compta', 'commerciale'] as $role) {
$client = $this->authAs($role); $client = $this->authAs($role);
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]); $client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role)); self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
$response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]); $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role)); self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
self::assertSame(
$totalSites,
$response->toArray()['totalItems'] ?? null,
sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites),
);
} }
// Usine : aucune permission -> reste a 403 sur les referentiels. // Usine : aucune permission -> reste a 403 sur les referentiels.