From 1ab2eecccad84667535e6b7ac01759a7e153cefb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 29 Jun 2026 10:15:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(rbac)=20:=20rattache=20le=20r=C3=B4le=20de?= =?UTF-8?q?=20base=20=C2=AB=20user=20=C2=BB=20et=20gate=20le=20frontend=20?= =?UTF-8?q?par=20permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un user avec des permissions sur le rôle RBAC « user » ne voyait rien : le ROLE_USER legacy n'a aucun lien avec le RBAC et getEffectivePermissions() ne lit que rbacRoles + permissions directes, alors qu'aucun user n'était rattaché au rôle « user » (table user_role vide, jamais backfillée). Backend - DefaultUserRoleAssigner + UserDefaultRoleListener (prePersist) : tout nouvel utilisateur est rattaché au rôle « user » sur tous les chemins de persistance. - Commande app:assign-default-roles (backfill idempotent) + ajout au deploy.sh. - AppFixtures : seed des rôles système avant la création des users. Frontend (gating par permission au lieu de ROLE_ADMIN legacy) - Nouveau middleware « permission » + augmentation PageMeta : definePageMeta ({ permission }) (string = requise, array = any), ROLE_ADMIN bypasse. - Pages directory/reporting/admin gatées par permission ; SidebarFilter accepte une liste de permissions (any) ; section admin sans gate de rôle. - team-absences reste en ROLE_ADMIN (module Absence non RBAC-isé côté backend). --- CLAUDE.md | 3 +- config/services.yaml | 4 + config/sidebar.php | 10 ++- frontend/app/middleware/permission.ts | 23 ++++++ frontend/app/types/page-meta.d.ts | 16 ++++ .../pages/directory/clients/[id].vue | 2 +- .../directory/pages/directory/index.vue | 2 +- .../pages/directory/prestataires/[id].vue | 2 +- .../pages/directory/prospects/[id].vue | 2 +- .../modules/reporting/pages/reporting.vue | 2 +- frontend/pages/admin.vue | 2 +- infra/prod/deploy.sh | 3 + src/DataFixtures/AppFixtures.php | 9 ++- .../Rbac/DefaultUserRoleAssigner.php | 76 +++++++++++++++++++ .../Console/AssignDefaultRolesCommand.php | 35 +++++++++ .../EventListener/UserDefaultRoleListener.php | 26 +++++++ src/Shared/Domain/Sidebar/SidebarFilter.php | 23 ++++-- .../Core/AssignDefaultRolesCommandTest.php | 58 ++++++++++++++ .../Core/UserDefaultRoleListenerTest.php | 45 +++++++++++ .../Unit/Shared/Sidebar/SidebarFilterTest.php | 29 +++++++ 20 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 frontend/app/middleware/permission.ts create mode 100644 frontend/app/types/page-meta.d.ts create mode 100644 src/Module/Core/Application/Rbac/DefaultUserRoleAssigner.php create mode 100644 src/Module/Core/Infrastructure/Console/AssignDefaultRolesCommand.php create mode 100644 src/Module/Core/Infrastructure/EventListener/UserDefaultRoleListener.php create mode 100644 tests/Functional/Module/Core/AssignDefaultRolesCommandTest.php create mode 100644 tests/Functional/Module/Core/UserDefaultRoleListenerTest.php diff --git a/CLAUDE.md b/CLAUDE.md index eae2298..ba7efad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,9 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. ## Déploiement (prod Docker) - Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md` -- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup +- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → **`app:assign-default-roles`** → cache clear/warmup - **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »). +- **Rattachement au rôle de base** : deux systèmes de rôles coexistent — le legacy `User::$roles` (`ROLE_USER`/`ROLE_ADMIN`, tableau Symfony) et le RBAC `User::$rbacRoles` (table `user_role`). **Aucun pont automatique** : `getEffectivePermissions()` ne lit que les `rbacRoles` + permissions directes. Un user doit donc être **explicitement rattaché** au rôle RBAC « user » pour hériter de ses permissions. C'est garanti automatiquement par `UserDefaultRoleListener` (prePersist, tout nouveau user) et `app:assign-default-roles` (backfill idempotent des users existants, lancé au déploiement). Symptôme si manquant : un non-admin avec des permissions sur le rôle « user » ne voit **rien** car son `effectivePermissions` reste `[]`. Les modifs de permissions d'un rôle sont **instantanées** côté backend (recalcul à chaque requête, sans cache) ; le frontend les reflète au prochain chargement de page (cache de session Pinia). ## Fixtures diff --git a/config/services.yaml b/config/services.yaml index 6590355..230357b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -129,6 +129,10 @@ services: tags: - { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist } + App\Module\Core\Infrastructure\EventListener\UserDefaultRoleListener: + tags: + - { name: doctrine.orm.entity_listener, entity: 'App\Module\Core\Domain\Entity\User', event: prePersist } + App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor: arguments: $uploadDir: '%task_document_upload_dir%' diff --git a/config/sidebar.php b/config/sidebar.php index 75e1312..5900ff5 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -38,12 +38,16 @@ return [ ], ], [ + // Plus de gate de rôle au niveau section : chaque item porte sa propre + // permission (RBAC fin), alignée sur la sécurité backend et les middlewares + // de page. La section s'affiche dès qu'au moins un item est autorisé. 'label' => 'sidebar.admin.section', 'icon' => 'mdi:cog-outline', - 'roles' => ['ROLE_ADMIN'], 'items' => [ - ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], - ['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'], + // team-absences : le module Absence est encore gardé par ROLE_ADMIN côté + // backend (pas de permission absence.* câblée) → on reste sur un gate de rôle. + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence', 'roles' => ['ROLE_ADMIN']], + ['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory', 'permission' => ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view']], ['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], ], diff --git a/frontend/app/middleware/permission.ts b/frontend/app/middleware/permission.ts new file mode 100644 index 0000000..52ef195 --- /dev/null +++ b/frontend/app/middleware/permission.ts @@ -0,0 +1,23 @@ +export default defineNuxtRouteMiddleware((to) => { + const auth = useAuthStore() + + if (!auth.isAuthenticated) { + return navigateTo('/login') + } + + // Gate the route on the RBAC permission(s) declared via definePageMeta. + // A string requires that single permission; an array requires ANY of them. + // ROLE_ADMIN bypasses everything through usePermissions().can(). + const required = to.meta.permission + + if (required === undefined) { + return + } + + const { canAny } = usePermissions() + const codes = Array.isArray(required) ? required : [required] + + if (!canAny(codes)) { + return navigateTo('/') + } +}) diff --git a/frontend/app/types/page-meta.d.ts b/frontend/app/types/page-meta.d.ts new file mode 100644 index 0000000..30e35f2 --- /dev/null +++ b/frontend/app/types/page-meta.d.ts @@ -0,0 +1,16 @@ +// Augments Nuxt page meta with the RBAC permission gate consumed by the +// `permission` route middleware. A string requires that single permission; +// an array requires ANY of the listed permissions. +declare module '#app' { + interface PageMeta { + permission?: string | string[] + } +} + +declare module 'vue-router' { + interface RouteMeta { + permission?: string | string[] + } +} + +export {} diff --git a/frontend/modules/directory/pages/directory/clients/[id].vue b/frontend/modules/directory/pages/directory/clients/[id].vue index d5a5990..fe3723d 100644 --- a/frontend/modules/directory/pages/directory/clients/[id].vue +++ b/frontend/modules/directory/pages/directory/clients/[id].vue @@ -136,7 +136,7 @@ import type { Client } from '~/modules/directory/services/dto/client' import { useClientService } from '~/modules/directory/services/clients' import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation' -definePageMeta({ middleware: ['admin'] }) +definePageMeta({ middleware: ['permission'], permission: 'directory.clients.view' }) const route = useRoute() const router = useRouter() diff --git a/frontend/modules/directory/pages/directory/index.vue b/frontend/modules/directory/pages/directory/index.vue index 4f7dd5a..38b2dfe 100644 --- a/frontend/modules/directory/pages/directory/index.vue +++ b/frontend/modules/directory/pages/directory/index.vue @@ -210,7 +210,7 @@ import type { Prestataire } from '~/modules/directory/services/dto/prestataire' import { usePrestataireService } from '~/modules/directory/services/prestataires' import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab' -definePageMeta({ middleware: ['admin'] }) +definePageMeta({ middleware: ['permission'], permission: ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view'] }) type ProspectRow = Prospect diff --git a/frontend/modules/directory/pages/directory/prestataires/[id].vue b/frontend/modules/directory/pages/directory/prestataires/[id].vue index 4319fa6..c62a0ee 100644 --- a/frontend/modules/directory/pages/directory/prestataires/[id].vue +++ b/frontend/modules/directory/pages/directory/prestataires/[id].vue @@ -136,7 +136,7 @@ import type { Prestataire } from '~/modules/directory/services/dto/prestataire' import { usePrestataireService } from '~/modules/directory/services/prestataires' import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation' -definePageMeta({ middleware: ['admin'] }) +definePageMeta({ middleware: ['permission'], permission: 'directory.providers.view' }) const route = useRoute() const router = useRouter() diff --git a/frontend/modules/directory/pages/directory/prospects/[id].vue b/frontend/modules/directory/pages/directory/prospects/[id].vue index 6c4344b..2a4a431 100644 --- a/frontend/modules/directory/pages/directory/prospects/[id].vue +++ b/frontend/modules/directory/pages/directory/prospects/[id].vue @@ -158,7 +158,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/ import { useProspectService } from '~/modules/directory/services/prospects' import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation' -definePageMeta({ middleware: ['admin'] }) +definePageMeta({ middleware: ['permission'], permission: 'directory.prospects.view' }) const route = useRoute() const router = useRouter() diff --git a/frontend/modules/reporting/pages/reporting.vue b/frontend/modules/reporting/pages/reporting.vue index a00c16c..c73a6ad 100644 --- a/frontend/modules/reporting/pages/reporting.vue +++ b/frontend/modules/reporting/pages/reporting.vue @@ -206,7 +206,7 @@ import type { UserData } from '~/services/dto/user-data' import { useProjectService } from '~/modules/project-management/services/projects' import { useUserService } from '~/services/users' -definePageMeta({ middleware: ['admin'] }) +definePageMeta({ middleware: ['permission'], permission: 'reporting.view' }) const { t } = useI18n() useHead({ title: t('reporting.title') }) diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index c4a3c3f..27b41fa 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -40,7 +40,7 @@