feat(core) : gate sidebar by effective permissions
This commit is contained in:
+7
-4
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||||
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
|
* Filtrée par SidebarFilter :
|
||||||
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
|
* - `module` : route ajoutée à disabledRoutes si module inactif ;
|
||||||
* le RBAC fin par permission arrive en #1.2).
|
* - `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
|
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||||
@@ -28,7 +31,7 @@ return [
|
|||||||
'roles' => ['ROLE_ADMIN'],
|
'roles' => ['ROLE_ADMIN'],
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
['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'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ namespace App\Shared\Domain\Sidebar;
|
|||||||
final class SidebarFilter
|
final class SidebarFilter
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
|
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:string}>}> $sections
|
||||||
* @param list<string> $activeModuleIds
|
* @param list<string> $activeModuleIds
|
||||||
* @param list<string> $activeRoles
|
* @param list<string> $activeRoles
|
||||||
|
* @param list<string> $activePermissions
|
||||||
*
|
*
|
||||||
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
|
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
|
||||||
*/
|
*/
|
||||||
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 = [];
|
$outSections = [];
|
||||||
$disabledRoutes = [];
|
$disabledRoutes = [];
|
||||||
@@ -24,6 +25,11 @@ final class SidebarFilter
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate de permission au niveau section (RBAC fin).
|
||||||
|
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($section['items'] as $item) {
|
foreach ($section['items'] as $item) {
|
||||||
// Gate de rôle au niveau item.
|
// Gate de rôle au niveau item.
|
||||||
@@ -31,6 +37,11 @@ final class SidebarFilter
|
|||||||
continue;
|
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).
|
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
|
||||||
$module = $item['module'] ?? null;
|
$module = $item['module'] ?? null;
|
||||||
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
|
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
|
||||||
@@ -68,4 +79,16 @@ final class SidebarFilter
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $activePermissions
|
||||||
|
*/
|
||||||
|
private static function permissionSatisfied(?string $required, array $activePermissions): bool
|
||||||
|
{
|
||||||
|
if (null === $required || '' === $required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($required, $activePermissions, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Shared\Domain\Contract\UserInterface;
|
||||||
use App\Shared\Domain\Module\ModuleRegistry;
|
use App\Shared\Domain\Module\ModuleRegistry;
|
||||||
use App\Shared\Domain\Sidebar\SidebarFilter;
|
use App\Shared\Domain\Sidebar\SidebarFilter;
|
||||||
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
||||||
@@ -31,7 +32,15 @@ final readonly class SidebarProvider implements ProviderInterface
|
|||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
$roles = null !== $user ? $user->getRoles() : [];
|
$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 = new SidebarResource();
|
||||||
$dto->sections = $filtered['sections'];
|
$dto->sections = $filtered['sections'];
|
||||||
|
|||||||
@@ -103,4 +103,28 @@ final class SidebarFilterTest extends TestCase
|
|||||||
self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
|
self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
|
||||||
self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]);
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user