From 9705b335efe9b908ef9dd670fdebb8455c0771a5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 23 Jun 2026 17:05:33 +0200 Subject: [PATCH 1/6] fix(rbac) : enforce granular permissions on business resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les ressources métier (ProjectManagement, Directory, TimeTracking) étaient gardées par is_granted('ROLE_USER')/'ROLE_ADMIN', ignorant les permissions RBAC granulaires déclarées par les modules : un utilisateur sans permission voyait quand même projets, tâches, clients, etc. - PermissionVoter : le regex excluait les tirets, donc project-management.* et time-tracking.* n'étaient supportées par aucun voter (refus pour tous, admin compris car le bypass ROLE_ADMIN est interne au voter). Ajout du tiret. - Câblage des permissions *.view (lecture) / *.manage (écriture) sur les 17 ressources métier. Métadonnées tâches lisibles via projects.view OR tasks.view. Directory partagé client/prospect via clients.* OR prospects.*. TimeEntry conserve le self-service (object.getUser() == user). - Sidebar : gating par permission effective des onglets Projets / Mes tâches / Suivi du temps (config/sidebar.php). - Test fonctionnel ProjectAccessControlTest (0 perm -> 403, view -> 200, view ne donne pas l'écriture -> 403). --- config/sidebar.php | 6 +- .../Security/PermissionVoter.php | 4 +- .../Directory/Domain/Entity/Address.php | 10 +-- src/Module/Directory/Domain/Entity/Client.php | 10 +-- .../Domain/Entity/CommercialReport.php | 10 +-- .../Directory/Domain/Entity/Contact.php | 10 +-- .../Directory/Domain/Entity/Prospect.php | 12 +-- .../Domain/Entity/ReportDocument.php | 8 +- .../Domain/Entity/Project.php | 12 +-- .../ProjectManagement/Domain/Entity/Task.php | 10 +-- .../Domain/Entity/TaskDocument.php | 8 +- .../Domain/Entity/TaskEffort.php | 10 +-- .../Domain/Entity/TaskGroup.php | 10 +-- .../Domain/Entity/TaskPriority.php | 10 +-- .../Domain/Entity/TaskRecurrence.php | 10 +-- .../Domain/Entity/TaskStatus.php | 10 +-- .../Domain/Entity/TaskTag.php | 10 +-- .../Domain/Entity/Workflow.php | 10 +-- .../TimeTracking/Domain/Entity/TimeEntry.php | 14 ++-- .../ProjectAccessControlTest.php | 82 +++++++++++++++++++ 20 files changed, 175 insertions(+), 91 deletions(-) create mode 100644 tests/Functional/Module/ProjectManagement/ProjectAccessControlTest.php diff --git a/config/sidebar.php b/config/sidebar.php index 1d9ad69..b4761d4 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -23,9 +23,9 @@ return [ 'icon' => 'mdi:view-dashboard-outline', 'items' => [ ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], - ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'], - ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'], - ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'], + ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'], + ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'], + ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'], // Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout. ['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'], ], diff --git a/src/Module/Core/Infrastructure/Security/PermissionVoter.php b/src/Module/Core/Infrastructure/Security/PermissionVoter.php index 542e220..36262fc 100644 --- a/src/Module/Core/Infrastructure/Security/PermissionVoter.php +++ b/src/Module/Core/Infrastructure/Security/PermissionVoter.php @@ -14,7 +14,9 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter; */ final class PermissionVoter extends Voter { - private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'; + // Les codes de permission sont au format module.resource.action où chaque + // segment peut contenir des tirets (ex. project-management, time-tracking). + private const string PATTERN = '/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/'; protected function supports(string $attribute, mixed $subject): bool { diff --git a/src/Module/Directory/Domain/Entity/Address.php b/src/Module/Directory/Domain/Entity/Address.php index 00c2e45..0e8740f 100644 --- a/src/Module/Directory/Domain/Entity/Address.php +++ b/src/Module/Directory/Domain/Entity/Address.php @@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Auditable] #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), ], normalizationContext: ['groups' => ['address:read']], denormalizationContext: ['groups' => ['address:write']], diff --git a/src/Module/Directory/Domain/Entity/Client.php b/src/Module/Directory/Domain/Entity/Client.php index 9d420be..6ca461f 100644 --- a/src/Module/Directory/Domain/Entity/Client.php +++ b/src/Module/Directory/Domain/Entity/Client.php @@ -25,11 +25,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Auditable] #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view')"), + new Get(security: "is_granted('directory.clients.view')"), + new Post(security: "is_granted('directory.clients.manage')"), + new Patch(security: "is_granted('directory.clients.manage')"), + new Delete(security: "is_granted('directory.clients.manage')"), ], normalizationContext: ['groups' => ['client:read']], denormalizationContext: ['groups' => ['client:write']], diff --git a/src/Module/Directory/Domain/Entity/CommercialReport.php b/src/Module/Directory/Domain/Entity/CommercialReport.php index ce55791..6f5a7b8 100644 --- a/src/Module/Directory/Domain/Entity/CommercialReport.php +++ b/src/Module/Directory/Domain/Entity/CommercialReport.php @@ -26,11 +26,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), ], normalizationContext: ['groups' => ['commercial_report:read']], denormalizationContext: ['groups' => ['commercial_report:write']], diff --git a/src/Module/Directory/Domain/Entity/Contact.php b/src/Module/Directory/Domain/Entity/Contact.php index 0f9e1d5..e44ae47 100644 --- a/src/Module/Directory/Domain/Entity/Contact.php +++ b/src/Module/Directory/Domain/Entity/Contact.php @@ -23,11 +23,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Auditable] #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), + new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), ], normalizationContext: ['groups' => ['contact:read']], denormalizationContext: ['groups' => ['contact:write']], diff --git a/src/Module/Directory/Domain/Entity/Prospect.php b/src/Module/Directory/Domain/Entity/Prospect.php index cd0f213..775d2d0 100644 --- a/src/Module/Directory/Domain/Entity/Prospect.php +++ b/src/Module/Directory/Domain/Entity/Prospect.php @@ -27,14 +27,14 @@ use Symfony\Component\Serializer\Attribute\Groups; #[Auditable] #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.prospects.view')"), + new Get(security: "is_granted('directory.prospects.view')"), + new Post(security: "is_granted('directory.prospects.manage')"), + new Patch(security: "is_granted('directory.prospects.manage')"), + new Delete(security: "is_granted('directory.prospects.manage')"), new Post( uriTemplate: '/prospects/{id}/convert', - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('directory.prospects.manage')", processor: ConvertProspectProcessor::class, ), ], diff --git a/src/Module/Directory/Domain/Entity/ReportDocument.php b/src/Module/Directory/Domain/Entity/ReportDocument.php index 4c4a8b5..b23273b 100644 --- a/src/Module/Directory/Domain/Entity/ReportDocument.php +++ b/src/Module/Directory/Domain/Entity/ReportDocument.php @@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), + new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Post( - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')", processor: ReportDocumentProcessor::class, deserialize: false, ), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), ], normalizationContext: ['groups' => ['report_document:read']], denormalizationContext: ['groups' => ['report_document:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/Project.php b/src/Module/ProjectManagement/Domain/Entity/Project.php index 793883e..b788704 100644 --- a/src/Module/ProjectManagement/Domain/Entity/Project.php +++ b/src/Module/ProjectManagement/Domain/Entity/Project.php @@ -30,18 +30,18 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view')"), + new Get(security: "is_granted('project-management.projects.view')"), new Post( - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('project-management.projects.manage')", denormalizationContext: ['groups' => ['project:write', 'project:create']], ), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new Patch(security: "is_granted('project-management.projects.manage')"), + new Delete(security: "is_granted('project-management.projects.manage')"), new Post( uriTemplate: '/projects/{id}/switch-workflow', uriVariables: ['id' => new Link(fromClass: Project::class)], - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('project-management.projects.manage')", input: false, output: SwitchWorkflowOutput::class, normalizationContext: ['groups' => ['switch_workflow:read']], diff --git a/src/Module/ProjectManagement/Domain/Entity/Task.php b/src/Module/ProjectManagement/Domain/Entity/Task.php index 661a531..7725ac5 100644 --- a/src/Module/ProjectManagement/Domain/Entity/Task.php +++ b/src/Module/ProjectManagement/Domain/Entity/Task.php @@ -33,11 +33,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class), - new Patch(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), - new Delete(security: "is_granted('ROLE_ADMIN')", processor: TaskCalendarProcessor::class), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.tasks.manage')", processor: TaskNumberProcessor::class), + new Patch(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class), + new Delete(security: "is_granted('project-management.tasks.manage')", processor: TaskCalendarProcessor::class), ], normalizationContext: ['groups' => ['task:read']], denormalizationContext: ['groups' => ['task:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskDocument.php b/src/Module/ProjectManagement/Domain/Entity/TaskDocument.php index 3d2d5f3..1e1645a 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskDocument.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskDocument.php @@ -21,14 +21,14 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class), - new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class), + new Get(security: "is_granted('project-management.tasks.view')", provider: TaskDocumentProvider::class), new Post( - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('project-management.tasks.manage')", processor: TaskDocumentProcessor::class, deserialize: false, ), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new Delete(security: "is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_document:read']], denormalizationContext: ['groups' => ['task_document:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskEffort.php b/src/Module/ProjectManagement/Domain/Entity/TaskEffort.php index 5dabd51..4d67786 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskEffort.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskEffort.php @@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_effort:read']], denormalizationContext: ['groups' => ['task_effort:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskGroup.php b/src/Module/ProjectManagement/Domain/Entity/TaskGroup.php index c013da7..5c5193d 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskGroup.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskGroup.php @@ -19,11 +19,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_group:read']], denormalizationContext: ['groups' => ['task_group:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskPriority.php b/src/Module/ProjectManagement/Domain/Entity/TaskPriority.php index 9840bee..2c2572d 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskPriority.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskPriority.php @@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_priority:read']], denormalizationContext: ['groups' => ['task_priority:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskRecurrence.php b/src/Module/ProjectManagement/Domain/Entity/TaskRecurrence.php index eb82b3e..e2b3362 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskRecurrence.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskRecurrence.php @@ -20,11 +20,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_recurrence:read']], denormalizationContext: ['groups' => ['task_recurrence:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskStatus.php b/src/Module/ProjectManagement/Domain/Entity/TaskStatus.php index 301d417..4797808 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskStatus.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskStatus.php @@ -18,11 +18,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_status:read']], denormalizationContext: ['groups' => ['task_status:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/TaskTag.php b/src/Module/ProjectManagement/Domain/Entity/TaskTag.php index 136cc91..7f8ec7f 100644 --- a/src/Module/ProjectManagement/Domain/Entity/TaskTag.php +++ b/src/Module/ProjectManagement/Domain/Entity/TaskTag.php @@ -17,11 +17,11 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')"), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), ], normalizationContext: ['groups' => ['task_tag:read']], denormalizationContext: ['groups' => ['task_tag:write']], diff --git a/src/Module/ProjectManagement/Domain/Entity/Workflow.php b/src/Module/ProjectManagement/Domain/Entity/Workflow.php index 1afa1a3..f69b962 100644 --- a/src/Module/ProjectManagement/Domain/Entity/Workflow.php +++ b/src/Module/ProjectManagement/Domain/Entity/Workflow.php @@ -21,11 +21,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_ADMIN')"), - new Patch(security: "is_granted('ROLE_ADMIN')"), - new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class), + new GetCollection(paginationEnabled: false, security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Get(security: "is_granted('project-management.projects.view') or is_granted('project-management.tasks.view')"), + new Post(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Patch(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')"), + new Delete(security: "is_granted('project-management.projects.manage') or is_granted('project-management.tasks.manage')", processor: WorkflowDeleteProcessor::class), ], normalizationContext: ['groups' => ['workflow:read']], denormalizationContext: ['groups' => ['workflow:write']], diff --git a/src/Module/TimeTracking/Domain/Entity/TimeEntry.php b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php index 1ed090d..5da02ee 100644 --- a/src/Module/TimeTracking/Domain/Entity/TimeEntry.php +++ b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php @@ -31,13 +31,13 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('time-tracking.entries.view')"), new GetCollection( name: 'time_entries_range', uriTemplate: '/time_entries/range', description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)', paginationEnabled: false, - security: "is_granted('ROLE_USER')", + security: "is_granted('time-tracking.entries.view')", ), new GetCollection( name: 'active_time_entry', @@ -45,12 +45,12 @@ use Symfony\Component\Serializer\Attribute\Groups; provider: ActiveTimeEntryProvider::class, description: 'Get the active timer for the current user', paginationEnabled: false, - security: "is_granted('ROLE_USER')", + security: "is_granted('time-tracking.entries.view')", ), - new Get(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_USER')"), - new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), - new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), + new Get(security: "is_granted('time-tracking.entries.view')"), + new Post(security: "is_granted('time-tracking.entries.view')"), + new Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.view') and object.getUser() == user)"), + new Delete(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.view') and object.getUser() == user)"), ], normalizationContext: ['groups' => ['time_entry:read']], denormalizationContext: ['groups' => ['time_entry:write']], diff --git a/tests/Functional/Module/ProjectManagement/ProjectAccessControlTest.php b/tests/Functional/Module/ProjectManagement/ProjectAccessControlTest.php new file mode 100644 index 0000000..455aad5 --- /dev/null +++ b/tests/Functional/Module/ProjectManagement/ProjectAccessControlTest.php @@ -0,0 +1,82 @@ +get(EntityManagerInterface::class); + + $user = $this->createPlainUser($em, 'proj-noperm-'.uniqid()); + $em->flush(); + $client->loginUser($user); + + $client->request('GET', '/api/projects'); + + self::assertResponseStatusCodeSame(403); + } + + public function testUserWithViewPermissionCanListProjects(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']); + self::assertInstanceOf(Permission::class, $permission, 'Le catalogue de permissions doit contenir project-management.projects.view (lancer app:sync-permissions).'); + + $user = $this->createPlainUser($em, 'proj-view-'.uniqid()); + $user->addDirectPermission($permission); + $em->flush(); + $client->loginUser($user); + + $client->request('GET', '/api/projects'); + + self::assertResponseIsSuccessful(); + } + + public function testViewPermissionDoesNotGrantWrite(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.view']); + self::assertInstanceOf(Permission::class, $permission); + + $user = $this->createPlainUser($em, 'proj-noWrite-'.uniqid()); + $user->addDirectPermission($permission); + $em->flush(); + $client->loginUser($user); + + $client->request('POST', '/api/projects', server: [ + 'CONTENT_TYPE' => 'application/ld+json', + ], content: json_encode(['name' => 'Should be denied'])); + + self::assertResponseStatusCodeSame(403); + } + + private function createPlainUser(EntityManagerInterface $em, string $username): User + { + $user = new User(); + $user->setUsername($username); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $em->persist($user); + + return $user; + } +} From 5e3607658acf55bf2ed450ccd45b0573a6ea31c2 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 23 Jun 2026 17:06:04 +0200 Subject: [PATCH 2/6] refactor(directory) : reduce client/prospect forms to company name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les formulaires d'ajout/édition client et prospect ne conservent que le champ « Nom société ». Les coordonnées (email, téléphone) et les champs prospect (société, statut, source, notes) sont retirés : ils seront gérés via Contact. Le statut prospect prend son défaut New à la création ; DTO assouplis, payload réduit à { name }. --- .../directory/components/ClientDrawer.vue | 28 +------ .../directory/components/ProspectDrawer.vue | 84 +------------------ .../modules/directory/services/dto/client.ts | 4 +- .../directory/services/dto/prospect.ts | 12 +-- 4 files changed, 14 insertions(+), 114 deletions(-) diff --git a/frontend/modules/directory/components/ClientDrawer.vue b/frontend/modules/directory/components/ClientDrawer.vue index 8534d6c..1413689 100644 --- a/frontend/modules/directory/components/ClientDrawer.vue +++ b/frontend/modules/directory/components/ClientDrawer.vue @@ -6,21 +6,11 @@
- -
props.modelValue, (open) => { if (open) { - if (props.client) { - form.name = props.client.name ?? '' - form.email = props.client.email ?? '' - form.phone = props.client.phone ?? '' - } else { - form.name = '' - form.email = '' - form.phone = '' - } + form.name = props.client?.name ?? '' touched.name = false - touched.email = false } }) @@ -93,8 +71,6 @@ async function handleSubmit() { try { const payload: ClientWrite = { name: form.name.trim(), - email: form.email.trim() || null, - phone: form.phone.trim() || null, } if (isEditing.value && props.client) { diff --git a/frontend/modules/directory/components/ProspectDrawer.vue b/frontend/modules/directory/components/ProspectDrawer.vue index 350939d..955623f 100644 --- a/frontend/modules/directory/components/ProspectDrawer.vue +++ b/frontend/modules/directory/components/ProspectDrawer.vue @@ -6,41 +6,11 @@ - - - - - -
+ + diff --git a/frontend/modules/directory/pages/directory/index.vue b/frontend/modules/directory/pages/directory/index.vue index 3803d64..e0fe487 100644 --- a/frontend/modules/directory/pages/directory/index.vue +++ b/frontend/modules/directory/pages/directory/index.vue @@ -31,6 +31,17 @@ +
@@ -75,20 +86,23 @@ {{ (item as ProspectRow).phone ?? '—' }}
@@ -105,6 +119,13 @@ :prospect="selectedProspect" @saved="onProspectSaved" /> + + @@ -139,6 +160,7 @@ const clientColumns = [ { key: 'name', label: t('prospects.fields.name') }, { key: 'email', label: t('prospects.fields.email') }, { key: 'phone', label: t('prospects.fields.phone') }, + { key: 'actions', label: '' }, ] async function loadClients() { @@ -225,6 +247,54 @@ async function onProspectSaved() { await Promise.all([loadProspects(), loadClients()]) } +// --- Suppression (clients & prospects) --- +type DeleteTarget = + | { type: 'client'; item: Client } + | { type: 'prospect'; item: Prospect } + +const deleteModalOpen = ref(false) +const deleteTarget = ref(null) + +const deleteModalTitle = computed(() => + deleteTarget.value?.type === 'prospect' + ? t('prospects.deleteConfirmTitle') + : t('clients.deleteConfirmTitle'), +) + +const deleteModalMessage = computed(() => { + if (!deleteTarget.value) return '' + const name = deleteTarget.value.item.name + return deleteTarget.value.type === 'prospect' + ? t('prospects.deleteConfirmMessage', { name }) + : t('clients.deleteConfirmMessage', { name }) +}) + +function askDeleteClient(item: Client) { + deleteTarget.value = { type: 'client', item } + deleteModalOpen.value = true +} + +function askDeleteProspect(item: Prospect) { + deleteTarget.value = { type: 'prospect', item } + deleteModalOpen.value = true +} + +async function confirmDelete() { + const target = deleteTarget.value + if (!target) return + + if (target.type === 'client') { + await clientService.remove(target.item.id) + await loadClients() + } else { + await prospectService.remove(target.item.id) + await loadProspects() + } + + deleteModalOpen.value = false + deleteTarget.value = null +} + watch(statusFilter, loadProspects) onMounted(async () => { From 6710c3015e777a6782abcf599672140a5dfe4eb5 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Tue, 23 Jun 2026 15:46:57 +0000 Subject: [PATCH 6/6] chore: bump version to v0.4.34 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index dd386b7..5fd5df1 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.4.33' + app.version: '0.4.34'