From bfed6ddca9ca60d13152b6067614e6b116033d40 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 14:51:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(technique)=20:=20c=C3=A2bler=20le=20RBAC?= =?UTF-8?q?=20technique.providers.*=20(3=20sources=20+=20matrice=20r=C3=B4?= =?UTF-8?q?les=20+=20bypass=5Fscope)=20(ERP-138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Câble les permissions du module Technique dans toutes les sources RBAC (règle ABSOLUE n°8, dans le même commit) : - RbacSeeder::MATRIX : bureau/compta/commerciale reçoivent technique.providers.* selon la matrice § 2.9 + sites.bypass_scope (visibilité multi-site, § 2.13) ; usine = technique.providers.view seul, SANS bypass (cloisonnée à son site). - config/sidebar.php : nouvelle section Technique + item Répertoire prestataires (/providers, module technique, permission technique.providers.view). - personas.ts + SeedE2ECommand.php : 5 perms technique.providers.* sur le persona user-full (porte déjà sites.bypass_scope) — pas de nouveau persona (règle n°7). - i18n fr.json : clés sidebar.technique.section / sidebar.technique.providers. Test : ProviderRBACMatrixTest (miroir SupplierRBACMatrixTest) valide la matrice rôle×verbe via app:seed-rbac, dont le cloisonnement par site de l'Usine (détail hors site → 404). 8 tests, 65 assertions. --- config/sidebar.php | 17 ++ frontend/i18n/locales/fr.json | 4 + frontend/tests/e2e/_fixtures/personas.ts | 11 + .../Core/Application/Rbac/RbacSeeder.php | 41 ++- .../Infrastructure/Console/SeedE2ECommand.php | 9 + .../Technique/Api/ProviderRBACMatrixTest.php | 279 ++++++++++++++++++ 6 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 tests/Module/Technique/Api/ProviderRBACMatrixTest.php diff --git a/config/sidebar.php b/config/sidebar.php index 9be6d4a..c193aa6 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -61,6 +61,23 @@ return [ ], ], ], + // Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le + // repertoire prestataires. L'item est gate par `technique.providers.view` ; + // la section disparait automatiquement (SidebarProvider) si le module + // `technique` est desactive ou si l'user n'a pas la permission. + [ + 'label' => 'sidebar.technique.section', + 'icon' => 'mdi:wrench-outline', + 'items' => [ + [ + 'label' => 'sidebar.technique.providers', + 'to' => '/providers', + 'icon' => 'mdi:account-wrench-outline', + 'module' => 'technique', + 'permission' => 'technique.providers.view', + ], + ], + ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 7f8819b..1ec6792 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -30,6 +30,10 @@ "clients": "Répertoire clients", "suppliers": "Répertoire fournisseurs" }, + "technique": { + "section": "Technique", + "providers": "Répertoire prestataires" + }, "core": { "roles": "Gestion des rôles", "users": "Utilisateurs", diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 73afbd4..ededce8 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -84,6 +84,17 @@ export const personas: Record = { 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme logique que + // clients/fournisseurs : mappe sur le persona "tout", pas de nouveau + // persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope, + // donc il voit les prestataires de tous les sites (M3 § 2.13). + // technique.providers.view n'ajoute pas de lien dans la section + // Administration, donc expectedAdminLinks reste inchange. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 20669e9..fd882a6 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -50,11 +50,19 @@ final class RbacSeeder /** * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a - * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il - * bypass tout via isAdmin ; `commercial.clients.archive` et - * `commercial.suppliers.archive` ne sont attaches a aucun role metier — + * attacher (admin n'apparait pas car il bypass tout via isAdmin ; + * `commercial.clients.archive`, `commercial.suppliers.archive` et + * `technique.providers.archive` ne sont attaches a aucun role metier — * admin seul). * + * Cloisonnement par site des prestataires (M3 § 2.13) : la permission + * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta / + * Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et + * reste cloisonnee a son site courant. Admin a le bypass total via isAdmin. + * C'est un cloisonnement pilote par user/permission, pas par code de role : + * pour cloisonner Bureau/Commerciale, il suffit de retirer la permission + * ici, aucun autre code a changer. + * * @var array}> */ private const array MATRIX = [ @@ -66,6 +74,11 @@ final class RbacSeeder // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -82,6 +95,13 @@ final class RbacSeeder 'commercial.suppliers.view', 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement + // (pas de manage global -> ne peut pas creer un prestataire). + 'technique.providers.view', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -96,14 +116,25 @@ final class RbacSeeder // (onglet Comptabilite masque/filtre pour la Commerciale). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting + // (onglet Comptabilite masque/filtre pour la Commerciale). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', ], ], self::ROLE_USINE => [ - 'label' => 'Usine', - 'permissions' => [], + 'label' => 'Usine', + // Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule, + // SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site + // courant. Aucun autre acces metier. + 'permissions' => [ + 'technique.providers.view', + ], ], ]; diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 7e7545b..bbef32d 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme + // logique : mappe sur le persona "tout". user-full porte deja + // sites.bypass_scope -> voit les prestataires de tous les + // sites (M3 § 2.13). Miroir de personas.ts. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], ], [ diff --git a/tests/Module/Technique/Api/ProviderRBACMatrixTest.php b/tests/Module/Technique/Api/ProviderRBACMatrixTest.php new file mode 100644 index 0000000..438f179 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderRBACMatrixTest.php @@ -0,0 +1,279 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame( + 0, + $exit, + 'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?', + ); + + self::ensureKernelShutdown(); + } + + public function testBureauHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedProvider('Bureau Cible'); + $client = $this->authAs('bureau'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK (bypass_scope -> peut attacher le site 86) + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Bureau Cree'), + ]); + self::assertResponseStatusCodeSame(201); + + // manage : edition onglet principal OK + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Bureau Renomme'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBureauDetailHasNoAccountingFields(): void + { + // Bureau a view mais PAS accounting.view : les champs comptables sont + // ABSENTS du JSON (gating par omission, pas null). + $provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789'); + $client = $this->authAs('bureau'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testComptaCanEditAccountingOnly(): void + { + $seed = $this->seedProvider('Compta Cible'); + $client = $this->authAs('compta'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Compta Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // accounting.manage : edition onglet Comptabilite OK + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : edition onglet principal refusee (mode strict RG-3.15) + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Compta Renomme'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testComptaDetailHasAccountingFields(): void + { + // Compta a accounting.view : siren + ribs presents dans le JSON. + $provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321'); + $this->addRib($provider); + $client = $this->authAs('compta'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('siren', $data); + self::assertSame('987654321', $data['siren']); + self::assertArrayHasKey('ribs', $data); + self::assertNotEmpty($data['ribs']); + } + + public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedProvider('Commerciale Cible'); + $client = $this->authAs('commerciale'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Commerciale Cree'), + ]); + self::assertResponseStatusCodeSame(201); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testCommercialeDetailHasNoAccountingFields(): void + { + $provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789'); + $client = $this->authAs('commerciale'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testUsineHasReadOnlyAccessScopedToItsSite(): void + { + // Usine a view (lecture seule), SANS manage / accounting / archive, et + // SANS bypass_scope -> cloisonnee a son site courant (Chatellerault, + // site 86, pose par ensureDemoUsers). + $inScope = $this->seedProvider('Usine InScope', [self::SITE_86]); + $client = $this->authAs('usine'); + + // view : liste OK (pas un 403 comme au M2) + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view : detail d'un prestataire de SON site OK + $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Usine Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // PAS manage : edition onglet principal refusee + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Renomme Par Usine'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testUsineCannotSeeProviderOutOfItsSite(): void + { + // Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine + // (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne). + $outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]); + $client = $this->authAs('usine'); + + $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(404); + } + + private function authAs(string $role): Client + { + return $this->authenticatedClient($role, self::PWD); + } +}