Compare commits

...

7 Commits

Author SHA1 Message Date
gitea-actions 3294b0c361 chore: bump version to v0.4.33
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 35s
2026-06-23 15:15:20 +00:00
matthieu 46e23874bd Merge pull request 'fix(rbac) : appliquer les permissions granulaires sur les ressources métier' (#19) from feat/rbac-enforcement into develop
Auto Tag Develop / tag (push) Successful in 12s
Reviewed-on: #19
2026-06-23 15:15:07 +00:00
Matthieu 4a7fd46493 fix(rbac) : add dedicated time-tracking.entries.manage permission
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m32s
La revue de sécurité a relevé que les écritures de TimeEntry (Post/Patch/Delete)
étaient gardées par time-tracking.entries.view : une permission de lecture
accordait l'écriture (confusion lecture/écriture, least-privilege).

- Ajout de la permission time-tracking.entries.manage (catalogue cohérent avec
  les autres modules en view/manage).
- Écritures TimeEntry recâblées sur entries.manage ; self-service conservé
  (object.getUser() == user). Lecture inchangée (entries.view).
2026-06-23 17:10:58 +02:00
Matthieu 5e3607658a refactor(directory) : reduce client/prospect forms to company name
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 4m39s
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 }.
2026-06-23 17:06:04 +02:00
Matthieu 9705b335ef fix(rbac) : enforce granular permissions on business resources
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).
2026-06-23 17:05:33 +02:00
gitea-actions 903030afbc chore: bump version to v0.4.32
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m58s
2026-06-23 14:25:40 +00:00
matthieu 961b7f56b4 Merge pull request 'Directory : save contacts/adresses au clic, denormalizer interface & seed RBAC au déploiement' (#18) from feat/directory-detail-save into develop
Auto Tag Develop / tag (push) Successful in 7s
Reviewed-on: #18
2026-06-23 14:25:33 +00:00
26 changed files with 191 additions and 209 deletions
+3 -3
View File
@@ -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'],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.31'
app.version: '0.4.33'
@@ -6,21 +6,11 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom"
label="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<div class="mt-6 flex justify-end">
<MalioButton
@@ -58,28 +48,16 @@ const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => 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) {
@@ -6,41 +6,11 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prospects.fields.name')"
label="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.company"
:label="$t('prospects.fields.company')"
input-class="w-full"
/>
<MalioInputText
v-model="form.email"
:label="$t('prospects.fields.email')"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
:label="$t('prospects.fields.phone')"
input-class="w-full"
/>
<MalioSelect
v-model="form.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="form.source"
:label="$t('prospects.fields.source')"
input-class="w-full"
/>
<MalioInputTextArea
v-model="form.notes"
:label="$t('prospects.fields.notes')"
/>
<div class="mt-6 flex items-center justify-between gap-2">
<MalioButton
@@ -69,7 +39,7 @@
</template>
<script setup lang="ts">
import type { Prospect, ProspectStatus, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import type { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
const props = defineProps<{
@@ -82,8 +52,6 @@ const emit = defineEmits<{
(e: 'saved'): void
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
@@ -93,30 +61,8 @@ const isEditing = computed(() => !!props.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false)
const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const form = reactive<{
name: string
company: string
email: string
phone: string
status: ProspectStatus
source: string
notes: string
}>({
const form = reactive({
name: '',
company: '',
email: '',
phone: '',
status: 'new',
source: '',
notes: '',
})
const touched = reactive({
@@ -125,23 +71,7 @@ const touched = reactive({
watch(() => props.modelValue, (open) => {
if (open) {
if (props.prospect) {
form.name = props.prospect.name ?? ''
form.company = props.prospect.company ?? ''
form.email = props.prospect.email ?? ''
form.phone = props.prospect.phone ?? ''
form.status = props.prospect.status ?? 'new'
form.source = props.prospect.source ?? ''
form.notes = props.prospect.notes ?? ''
} else {
form.name = ''
form.company = ''
form.email = ''
form.phone = ''
form.status = 'new'
form.source = ''
form.notes = ''
}
form.name = props.prospect?.name ?? ''
touched.name = false
}
})
@@ -156,12 +86,6 @@ async function handleSubmit() {
try {
const payload: ProspectWrite = {
name: form.name.trim(),
company: form.company.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
status: form.status,
source: form.source.trim() || null,
notes: form.notes.trim() || null,
}
if (isEditing.value && props.prospect) {
@@ -8,6 +8,6 @@ export type Client = {
export type ClientWrite = {
name: string
email: string | null
phone: string | null
email?: string | null
phone?: string | null
}
@@ -19,10 +19,10 @@ export type Prospect = {
export type ProspectWrite = {
name: string
company: string | null
email: string | null
phone: string | null
status: ProspectStatus
source: string | null
notes: string | null
company?: string | null
email?: string | null
phone?: string | null
status?: ProspectStatus
source?: string | null
notes?: string | null
}
@@ -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
{
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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,
),
],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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']],
@@ -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.manage')"),
new Patch(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
new Delete(security: "is_granted('ROLE_ADMIN') or (is_granted('time-tracking.entries.manage') and object.getUser() == user)"),
],
normalizationContext: ['groups' => ['time_entry:read']],
denormalizationContext: ['groups' => ['time_entry:write']],
@@ -26,15 +26,13 @@ final class TimeTrackingModule implements ModuleInterface
/**
* Permissions RBAC fin du Module TimeTracking (2.1).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
['code' => 'time-tracking.entries.manage', 'label' => 'Gérer les saisies de temps'],
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
];
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que les ressources métier sont bien gardées par les permissions RBAC
* granulaires et non plus par le simple ROLE_USER.
*
* @internal
*/
final class ProjectAccessControlTest extends WebTestCase
{
public function testAuthenticatedUserWithoutPermissionIsForbidden(): void
{
$client = self::createClient();
$em = self::getContainer()->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;
}
}