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 @@