fix(rbac) : référentiels /categories et /sites lisibles par les rôles métier (ERP-102) (#53)
Auto Tag Develop / tag (push) Successful in 11s

## Contexte

ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.

Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.

## Cause

La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.

## Correctif back — Option C (permission de lecture-référentiel dédiée)

Choix d'archi retenu parmi les 3 du ticket :

- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.

Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.

## Durcissement front (résilience — requis dans tous les cas)

`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.

## Tests (TDD)

- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.

## Vérifications

- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓

## Note miroirs RBAC

`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.

Closes ERP-102.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #53.
This commit is contained in:
2026-06-03 12:56:46 +00:00
committed by admin malio
parent 1961bc62c8
commit 1888b70623
9 changed files with 168 additions and 7 deletions
@@ -6,6 +6,7 @@ namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
@@ -272,6 +273,51 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(200);
}
public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void
{
// ERP-102 : /categories et /sites sont des referentiels TRANSVERSES.
// Tout role qui gere des clients (bureau / compta / commerciale) doit
// pouvoir les LISTER pour alimenter les selects de creation/filtre client,
// via la permission de lecture-referentiel dediee (catalog.categories.read_ref
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
// la permission d'administration `.view`. Usine, sans aucune permission,
// 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) {
$client = $this->authAs($role);
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
$response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
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 = $this->authAs('usine');
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
$usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites');
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
+4 -3
View File
@@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/
final class SitesModuleTest extends KernelTestCase
{
public function testPermissionsSetContainsExactlyThreeCodes(): void
public function testPermissionsSetContainsExactlyFourCodes(): void
{
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les
// tests ou la doc, ce test casse explicitement. Si au contraire une
// permission disparait (ex: bypass_scope retire par erreur), meme
// effet. Le set de 3 permissions est fige par ce test.
// effet. Le set de permissions est fige par ce test.
// `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse).
$codes = array_column(SitesModule::permissions(), 'code');
sort($codes);
self::assertSame(
['sites.bypass_scope', 'sites.manage', 'sites.view'],
['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'],
$codes,
);
}