diff --git a/config/sidebar.php b/config/sidebar.php index 79e4ee6..afd9826 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -4,9 +4,12 @@ declare(strict_types=1); /* * Définition de la sidebar (sections + items) — navigation GLOBALE uniquement. - * Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif), - * `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal, - * le RBAC fin par permission arrive en #1.2). + * Filtrée par SidebarFilter : + * - `module` : route ajoutée à disabledRoutes si module inactif ; + * - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ; + * - `permission` : section ou item masqué si la permission effective absente (RBAC fin — + * `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la + * sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin). * Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag * (Mes absences) restent rendus côté layout, hors de cet endpoint. * Les labels sont des clés i18n (sidebar..). @@ -28,7 +31,7 @@ return [ 'roles' => ['ROLE_ADMIN'], 'items' => [ ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], - ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], ], ], ]; diff --git a/src/Shared/Domain/Sidebar/SidebarFilter.php b/src/Shared/Domain/Sidebar/SidebarFilter.php index e740afa..e39f8c4 100644 --- a/src/Shared/Domain/Sidebar/SidebarFilter.php +++ b/src/Shared/Domain/Sidebar/SidebarFilter.php @@ -7,13 +7,14 @@ namespace App\Shared\Domain\Sidebar; final class SidebarFilter { /** - * @param list, items: list}>}> $sections - * @param list $activeModuleIds - * @param list $activeRoles + * @param list, permission?:string, items: list, permission?:string}>}> $sections + * @param list $activeModuleIds + * @param list $activeRoles + * @param list $activePermissions * * @return array{sections: list}>, disabledRoutes: list} */ - public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array + public static function filter(array $sections, array $activeModuleIds, array $activeRoles = [], array $activePermissions = []): array { $outSections = []; $disabledRoutes = []; @@ -24,6 +25,11 @@ final class SidebarFilter continue; } + // Gate de permission au niveau section (RBAC fin). + if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) { + continue; + } + $items = []; foreach ($section['items'] as $item) { // Gate de rôle au niveau item. @@ -31,6 +37,11 @@ final class SidebarFilter continue; } + // Gate de permission au niveau item (RBAC fin). + if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) { + continue; + } + // Filtrage par module actif (pilote la redirection front via disabledRoutes). $module = $item['module'] ?? null; if (null !== $module && !in_array($module, $activeModuleIds, true)) { @@ -68,4 +79,16 @@ final class SidebarFilter return false; } + + /** + * @param list $activePermissions + */ + private static function permissionSatisfied(?string $required, array $activePermissions): bool + { + if (null === $required || '' === $required) { + return true; + } + + return in_array($required, $activePermissions, true); + } } diff --git a/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php index ddcf48c..8f7ee43 100644 --- a/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php +++ b/src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php @@ -6,6 +6,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Module\ModuleRegistry; use App\Shared\Domain\Sidebar\SidebarFilter; use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource; @@ -31,7 +32,15 @@ final readonly class SidebarProvider implements ProviderInterface $user = $this->security->getUser(); $roles = null !== $user ? $user->getRoles() : []; - $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles)); + // RBAC fin : permissions effectives du contrat. ROLE_ADMIN bypasse tout (Décision 1) : + // on lui injecte le catalogue complet des permissions déclarées pour satisfaire les gates. + if (in_array('ROLE_ADMIN', $roles, true)) { + $permissions = array_column(ModuleRegistry::permissions($moduleClasses), 'code'); + } else { + $permissions = $user instanceof UserInterface ? $user->getEffectivePermissions() : []; + } + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions); $dto = new SidebarResource(); $dto->sections = $filtered['sections']; diff --git a/tests/Unit/Shared/Sidebar/SidebarFilterTest.php b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php index ac9315c..939c1f1 100644 --- a/tests/Unit/Shared/Sidebar/SidebarFilterTest.php +++ b/tests/Unit/Shared/Sidebar/SidebarFilterTest.php @@ -103,4 +103,28 @@ final class SidebarFilterTest extends TestCase self::assertSame('/x', $result['sections'][0]['items'][0]['to']); self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]); } + + public function testItemHiddenWhenPermissionMissing(): void + { + $sections = [[ + 'label' => 's', 'icon' => 'i', + 'items' => [ + ['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view'], + ['label' => 'b', 'to' => '/b', 'icon' => 'i'], + ], + ]]; + $out = SidebarFilter::filter($sections, [], [], []); + self::assertCount(1, $out['sections'][0]['items']); + self::assertSame('/b', $out['sections'][0]['items'][0]['to']); + } + + public function testItemVisibleWhenPermissionGranted(): void + { + $sections = [[ + 'label' => 's', 'icon' => 'i', + 'items' => [['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view']], + ]]; + $out = SidebarFilter::filter($sections, [], [], ['core.users.view']); + self::assertCount(1, $out['sections'][0]['items']); + } }