Compare commits
28 Commits
v0.1.31
...
4325b1d8a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4325b1d8a0 | ||
|
|
b7aa445cef | ||
|
|
fd4ed25c63 | ||
|
|
0ccbc70f27 | ||
|
|
534bdbccdd | ||
|
|
3c7dc88fe7 | ||
|
|
168a47f2b8 | ||
|
|
87aa1d0b04 | ||
|
|
d527fbe2d1 | ||
|
|
efc12c8bdb | ||
|
|
7be0260b29 | ||
|
|
f79f061131 | ||
|
|
fdb7aded82 | ||
|
|
1cf550721b | ||
|
|
46fa7d17ae | ||
|
|
0fc4e1651b | ||
|
|
d8bda517f9 | ||
|
|
7ccc913862 | ||
|
|
eb0b49a7ef | ||
|
|
0a496f34e0 | ||
|
|
aafe08b6ad | ||
|
|
d68aa0456a | ||
|
|
3b1f18b0e0 | ||
|
|
7aa32b1972 | ||
|
|
3b34d00872 | ||
|
|
0fc0b57e37 | ||
|
|
f0ea9201f5 | ||
|
|
e3025bf2c9 |
@@ -8,8 +8,6 @@ declare(strict_types=1);
|
||||
* This file defines the sidebar sections displayed in the frontend.
|
||||
* Each item references the module that owns it via the `module` key.
|
||||
* Items whose module is not active (see config/modules.php) are filtered out.
|
||||
* Items may also declare a `permission` key (RBAC permission code) : the item
|
||||
* is hidden from users who do not hold that permission.
|
||||
*
|
||||
* This config is decoupled from the modules themselves: you can freely
|
||||
* move an item from one section to another without touching the module code.
|
||||
@@ -34,20 +32,6 @@ return [
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'module' => 'core',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
'icon' => 'mdi:shield-account-outline',
|
||||
'module' => 'core',
|
||||
'permission' => 'core.roles.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.users',
|
||||
'to' => '/admin/users',
|
||||
'icon' => 'mdi:account-group-outline',
|
||||
'module' => 'core',
|
||||
'permission' => 'core.users.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.general.logout',
|
||||
'to' => '/logout',
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.31'
|
||||
app.version: '0.1.29'
|
||||
|
||||
@@ -22,10 +22,6 @@
|
||||
"commercial": {
|
||||
"section": "Commercial",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -60,65 +56,5 @@
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
"title": "Gestion des rôles",
|
||||
"newRole": "Nouveau rôle",
|
||||
"editRole": "Modifier le rôle",
|
||||
"createRole": "Créer un rôle",
|
||||
"noRoles": "Aucun rôle configuré",
|
||||
"table": {
|
||||
"label": "Libellé",
|
||||
"code": "Code",
|
||||
"permissions": "Permissions",
|
||||
"system": "Système"
|
||||
},
|
||||
"form": {
|
||||
"label": "Libellé",
|
||||
"code": "Code",
|
||||
"description": "Description",
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le rôle",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer le rôle \"{label}\" ? Cette action est irréversible.",
|
||||
"systemTooltip": "Rôle système non supprimable"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Rôle créé avec succès",
|
||||
"updated": "Rôle mis à jour avec succès",
|
||||
"deleted": "Rôle supprimé avec succès"
|
||||
},
|
||||
"permissions": {
|
||||
"selectAll": "Tout selectionner",
|
||||
"noPermissions": "Aucune permission disponible"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des utilisateurs",
|
||||
"noUsers": "Aucun utilisateur",
|
||||
"table": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"admin": "Administrateur",
|
||||
"roles": "Roles",
|
||||
"directPermissions": "Permissions directes"
|
||||
},
|
||||
"drawer": {
|
||||
"title": "Permissions de {username}",
|
||||
"selfWarning": "Vous modifiez vos propres droits",
|
||||
"adminToggle": "Administrateur (bypass total)",
|
||||
"rolesSection": "Rôles",
|
||||
"directPermissionsSection": "Permissions directes",
|
||||
"summarySection": "Résumé des permissions effectives",
|
||||
"noEffectivePermissions": "Aucune permission effective",
|
||||
"sourceRole": "via {role}",
|
||||
"sourceDirect": "Direct",
|
||||
"lastAdminWarning": "Impossible de retirer le statut administrateur du dernier admin"
|
||||
},
|
||||
"toast": {
|
||||
"updated": "Permissions mises à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="permissions.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.users.drawer.noEffectivePermissions') }}
|
||||
</div>
|
||||
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
v-for="perm in groupedPermissions"
|
||||
:key="perm.module"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<!-- En-tête du module -->
|
||||
<p class="text-xs font-semibold uppercase text-neutral-400 mb-1">
|
||||
{{ perm.module }}
|
||||
</p>
|
||||
<div
|
||||
v-for="item in perm.items"
|
||||
:key="item.code"
|
||||
class="flex items-center justify-between py-1"
|
||||
>
|
||||
<span class="text-sm text-neutral-700">{{ item.label }}</span>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-for="source in item.sources"
|
||||
:key="source"
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
source === t('admin.users.drawer.sourceDirect')
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
]"
|
||||
>
|
||||
{{ source }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EffectivePermission } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
permissions: EffectivePermission[]
|
||||
}>()
|
||||
|
||||
// Grouper par module pour l'affichage
|
||||
interface PermissionModuleGroup {
|
||||
module: string
|
||||
items: EffectivePermission[]
|
||||
}
|
||||
|
||||
const groupedPermissions = computed<PermissionModuleGroup[]>(() => {
|
||||
const groups = new Map<string, EffectivePermission[]>()
|
||||
for (const perm of props.permissions) {
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, items]) => ({ module, items }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
||||
<MalioCheckbox
|
||||
:id="`group-${module}`"
|
||||
:label="moduleLabel"
|
||||
:model-value="allSelected"
|
||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-neutral-400">
|
||||
{{ selectedCount }}/{{ permissions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des permissions individuelles -->
|
||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in permissions"
|
||||
:key="perm.id"
|
||||
:id="`perm-${perm.id}`"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from '~/shared/types/rbac'
|
||||
|
||||
const props = defineProps<{
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: Permission[]
|
||||
selectedIds: Set<number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
toggleAll: [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Nombre de permissions selectionnees dans ce groupe
|
||||
const selectedCount = computed(() =>
|
||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
)
|
||||
|
||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
||||
const allSelected = computed(() =>
|
||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
||||
)
|
||||
|
||||
// Emet l'evenement de bascule pour une permission individuelle
|
||||
function togglePermission(id: number, selected: boolean) {
|
||||
emit('toggle', id, selected)
|
||||
}
|
||||
|
||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
||||
function toggleAll(selected: boolean) {
|
||||
emit('toggleAll', props.module, selected)
|
||||
}
|
||||
</script>
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="cancel"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.roles.delete.title') }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ t('admin.roles.delete.message', { label: roleLabel }) }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
roleLabel: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
// Ferme la modale sans confirmer
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// Emet l'evenement de confirmation de suppression
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
// Fermer la modale avec la touche Escape
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,224 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="t('admin.roles.form.label')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:label="t('admin.roles.form.code')"
|
||||
input-class="w-full"
|
||||
required
|
||||
:readonly="isEditMode"
|
||||
/>
|
||||
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="t('admin.roles.form.description')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Permissions groupees par module -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.roles.form.permissions') }}
|
||||
</h4>
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
role: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
|
||||
const form = ref({
|
||||
label: '',
|
||||
code: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const selectedPermissionIds = ref(new Set<number>())
|
||||
|
||||
const isEditMode = computed(() => props.role !== null)
|
||||
|
||||
// Grouper les permissions par module
|
||||
const permissionsByModule = computed<PermissionModule[]>(() => {
|
||||
const groups = new Map<string, Permission[]>()
|
||||
for (const perm of allPermissions.value) {
|
||||
if (perm.orphan) continue
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
// Charger les permissions au montage
|
||||
async function loadPermissions() {
|
||||
const data = await api.get<{ member: Permission[] }>(
|
||||
'/permissions',
|
||||
{ 'orphan': false, itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
allPermissions.value = data.member
|
||||
}
|
||||
|
||||
// Remplir le formulaire quand le role change
|
||||
watch(() => props.role, (role) => {
|
||||
if (role) {
|
||||
form.value.label = role.label
|
||||
form.value.code = role.code
|
||||
form.value.description = role.description || ''
|
||||
selectedPermissionIds.value = new Set(role.permissions.map(p => {
|
||||
// L'API peut retourner des objets Permission ou des IRIs string
|
||||
if (typeof p === 'string') {
|
||||
return Number(p.split('/').pop())
|
||||
}
|
||||
return p.id
|
||||
}))
|
||||
} else {
|
||||
form.value.label = ''
|
||||
form.value.code = ''
|
||||
form.value.description = ''
|
||||
selectedPermissionIds.value = new Set()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Charger les permissions quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) loadPermissions()
|
||||
})
|
||||
|
||||
// Basculer une permission individuelle
|
||||
function handleTogglePermission(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedPermissionIds.value)
|
||||
if (selected) {
|
||||
ids.add(id)
|
||||
} else {
|
||||
ids.delete(id)
|
||||
}
|
||||
selectedPermissionIds.value = ids
|
||||
}
|
||||
|
||||
// Basculer toutes les permissions d'un module
|
||||
function handleToggleAll(module: string, selected: boolean) {
|
||||
const ids = new Set(selectedPermissionIds.value)
|
||||
const group = permissionsByModule.value.find(g => g.module === module)
|
||||
if (!group) return
|
||||
for (const perm of group.permissions) {
|
||||
if (selected) {
|
||||
ids.add(perm.id)
|
||||
} else {
|
||||
ids.delete(perm.id)
|
||||
}
|
||||
}
|
||||
selectedPermissionIds.value = ids
|
||||
}
|
||||
|
||||
// Sauvegarder le role (creation ou edition)
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const permissions = Array.from(selectedPermissionIds.value).map(id => `/api/permissions/${id}`)
|
||||
|
||||
if (isEditMode.value && props.role) {
|
||||
// Le code est immuable apres creation (garde backend RoleProcessor)
|
||||
await api.patch(`/roles/${props.role.id}`, {
|
||||
label: form.value.label,
|
||||
description: form.value.description || null,
|
||||
permissions,
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.updated'),
|
||||
})
|
||||
} else {
|
||||
await api.post('/roles', {
|
||||
label: form.value.label,
|
||||
code: form.value.code,
|
||||
description: form.value.description || null,
|
||||
permissions,
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.created'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,259 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Avertissement auto-edition -->
|
||||
<div
|
||||
v-if="isSelfEdit"
|
||||
class="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
|
||||
>
|
||||
<Icon name="mdi:alert-outline" class="size-5 shrink-0" />
|
||||
{{ t('admin.users.drawer.selfWarning') }}
|
||||
</div>
|
||||
|
||||
<!-- Toggle Administrateur -->
|
||||
<MalioCheckbox
|
||||
id="admin-toggle"
|
||||
:label="t('admin.users.drawer.adminToggle')"
|
||||
:model-value="form.isAdmin"
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="form.isAdmin = $event"
|
||||
/>
|
||||
|
||||
<!-- Section Roles -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.rolesSection') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
:id="`role-${role.id}`"
|
||||
:label="role.label"
|
||||
:model-value="selectedRoleIds.has(role.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => toggleRole(role.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Permissions directes -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.directPermissionsSection') }}
|
||||
</h4>
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Resume permissions effectives -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.summarySection') }}
|
||||
</h4>
|
||||
<EffectivePermissions :permissions="effectivePermissions" />
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserListItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
const allRoles = ref<Role[]>([])
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
|
||||
const form = ref({ isAdmin: false })
|
||||
const selectedRoleIds = ref(new Set<number>())
|
||||
const selectedDirectPermissionIds = ref(new Set<number>())
|
||||
|
||||
// Detecter l'auto-edition
|
||||
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
|
||||
|
||||
// Extraire un ID depuis une IRI API Platform
|
||||
function iriToId(iri: string): number {
|
||||
return Number(iri.split('/').pop())
|
||||
}
|
||||
|
||||
// Grouper les permissions par module (pour les checkboxes)
|
||||
const permissionsByModule = computed<PermissionModule[]>(() => {
|
||||
const groups = new Map<string, Permission[]>()
|
||||
for (const perm of allPermissions.value) {
|
||||
if (perm.orphan) continue
|
||||
const list = groups.get(perm.module) || []
|
||||
list.push(perm)
|
||||
groups.set(perm.module, list)
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
// Calculer les permissions effectives avec leurs sources
|
||||
const effectivePermissions = computed<EffectivePermission[]>(() => {
|
||||
const permMap = new Map<number, Permission>()
|
||||
for (const p of allPermissions.value) {
|
||||
if (!p.orphan) permMap.set(p.id, p)
|
||||
}
|
||||
|
||||
// Construire la map permissionId -> sources[]
|
||||
const result = new Map<number, string[]>()
|
||||
|
||||
// Permissions heritees des roles
|
||||
for (const roleId of selectedRoleIds.value) {
|
||||
const role = allRoles.value.find(r => r.id === roleId)
|
||||
if (!role) continue
|
||||
for (const p of role.permissions) {
|
||||
const pid = typeof p === 'string' ? iriToId(p) : p.id
|
||||
const sources = result.get(pid) || []
|
||||
sources.push(t('admin.users.drawer.sourceRole', { role: role.label }))
|
||||
result.set(pid, sources)
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions directes
|
||||
for (const pid of selectedDirectPermissionIds.value) {
|
||||
const sources = result.get(pid) || []
|
||||
sources.push(t('admin.users.drawer.sourceDirect'))
|
||||
result.set(pid, sources)
|
||||
}
|
||||
|
||||
// Construire la liste finale
|
||||
return Array.from(result.entries())
|
||||
.map(([pid, sources]) => {
|
||||
const perm = permMap.get(pid)
|
||||
if (!perm) return null
|
||||
return { code: perm.code, label: perm.label, module: perm.module, sources }
|
||||
})
|
||||
.filter((p): p is EffectivePermission => p !== null)
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
})
|
||||
|
||||
// Charger roles et permissions
|
||||
async function loadData() {
|
||||
const [rolesData, permsData] = await Promise.all([
|
||||
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
||||
])
|
||||
allRoles.value = rolesData.member
|
||||
allPermissions.value = permsData.member
|
||||
}
|
||||
|
||||
// Remplir le formulaire quand le user change
|
||||
watch(() => props.user, (user) => {
|
||||
if (user) {
|
||||
form.value.isAdmin = user.isAdmin
|
||||
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
||||
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
||||
} else {
|
||||
form.value.isAdmin = false
|
||||
selectedRoleIds.value = new Set()
|
||||
selectedDirectPermissionIds.value = new Set()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Charger les donnees quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) loadData()
|
||||
})
|
||||
|
||||
function toggleRole(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedRoleIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedRoleIds.value = ids
|
||||
}
|
||||
|
||||
function handleTogglePermission(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedDirectPermissionIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedDirectPermissionIds.value = ids
|
||||
}
|
||||
|
||||
function handleToggleAll(module: string, selected: boolean) {
|
||||
const ids = new Set(selectedDirectPermissionIds.value)
|
||||
const group = permissionsByModule.value.find(g => g.module === module)
|
||||
if (!group) return
|
||||
for (const perm of group.permissions) {
|
||||
if (selected) ids.add(perm.id)
|
||||
else ids.delete(perm.id)
|
||||
}
|
||||
selectedDirectPermissionIds.value = ids
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.user) return
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/users/${props.user.id}/rbac`, {
|
||||
isAdmin: form.value.isAdmin,
|
||||
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
|
||||
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.users.toast.updated'),
|
||||
})
|
||||
// Rafraichir les donnees du user courant si auto-edition
|
||||
if (isSelfEdit.value) {
|
||||
await auth.refreshUser()
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.roles.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
</template>
|
||||
<template #cell-permissions="{ item }">
|
||||
{{ item.permissions }}
|
||||
</template>
|
||||
<template #cell-system="{ item }">
|
||||
<span
|
||||
v-if="item.isSystem"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{{ t('admin.roles.table.system') }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer creation/edition -->
|
||||
<RoleDrawer
|
||||
v-model="drawerOpen"
|
||||
:role="selectedRole"
|
||||
@saved="onRoleSaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de suppression -->
|
||||
<RoleDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:role-label="roleToDelete?.label ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
{ key: 'code', label: t('admin.roles.table.code') },
|
||||
{ key: 'permissions', label: t('admin.roles.table.permissions') },
|
||||
{ key: 'system', label: t('admin.roles.table.system') },
|
||||
]
|
||||
|
||||
// Transformer les roles en items compatibles MalioDataTable
|
||||
const roleItems = computed(() =>
|
||||
roles.value.map(role => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
code: role.code,
|
||||
permissions: role.permissions.length,
|
||||
isSystem: role.isSystem,
|
||||
system: '', // colonne geree par le slot
|
||||
}))
|
||||
)
|
||||
|
||||
function getRoleById(id: number): Role | undefined {
|
||||
return roles.value.find(r => r.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const role = getRoleById(item.id as number)
|
||||
if (role) openEditDrawer(role)
|
||||
}
|
||||
const drawerOpen = ref(false)
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(role: Role) {
|
||||
selectedRole.value = role
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedRole.value || selectedRole.value.isSystem) return
|
||||
roleToDelete.value = selectedRole.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!roleToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete(`/roles/${roleToDelete.value.id}`, {}, {
|
||||
toastSuccessMessage: t('admin.roles.toast.deleted'),
|
||||
})
|
||||
deleteModalOpen.value = false
|
||||
roleToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await loadRoles()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSaved() {
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
})
|
||||
</script>
|
||||
@@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.users.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
v-if="item.admin"
|
||||
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
|
||||
>
|
||||
{{ t('admin.users.table.admin') }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer RBAC -->
|
||||
<UserRbacDrawer
|
||||
v-model="drawerOpen"
|
||||
:user="selectedUser"
|
||||
@saved="onUserSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||
]
|
||||
|
||||
const userItems = computed(() =>
|
||||
users.value.map(user => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
admin: user.isAdmin,
|
||||
roles: user.roles.length,
|
||||
directPermissions: user.directPermissions.length,
|
||||
}))
|
||||
)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: UserListItem[] }>(
|
||||
'/users',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
users.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDrawer(user: UserListItem) {
|
||||
selectedUser.value = user
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const user = getUserById(item.id as number)
|
||||
if (user) openDrawer(user)
|
||||
}
|
||||
|
||||
function onUserSaved() {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
2448
frontend/package-lock.json
generated
2448
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,10 @@
|
||||
"postinstall": "nuxt prepare",
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.3.0",
|
||||
"@malio/layer-ui": "^1.2.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -30,11 +28,8 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"vitest": "^4.1.4",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePermissions } from '../usePermissions'
|
||||
|
||||
// Mock du store auth : le composable ne depend que de auth.user.
|
||||
const mockUser = vi.hoisted(() => ({
|
||||
value: null as { isAdmin: boolean; effectivePermissions: string[] } | null,
|
||||
}))
|
||||
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
get user() {
|
||||
return mockUser.value
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('usePermissions', () => {
|
||||
beforeEach(() => {
|
||||
mockUser.value = null
|
||||
})
|
||||
|
||||
it('refuse toute permission quand aucun utilisateur n\'est connecte', () => {
|
||||
const { can, canAny, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(false)
|
||||
expect(canAny(['core.users.view', 'core.roles.view'])).toBe(false)
|
||||
expect(canAll(['core.users.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('accorde toutes les permissions a un admin via le bypass', () => {
|
||||
mockUser.value = { isAdmin: true, effectivePermissions: [] }
|
||||
const { can, canAll } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
expect(can('module.inexistante.action')).toBe(true)
|
||||
expect(canAll(['a.b.c', 'd.e.f'])).toBe(true)
|
||||
})
|
||||
|
||||
it('accorde une permission presente dans effectivePermissions', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.users.view')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuse une permission absente pour un non-admin', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { can } = usePermissions()
|
||||
expect(can('core.roles.manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('canAny retourne true si au moins un code matche', () => {
|
||||
mockUser.value = { isAdmin: false, effectivePermissions: ['core.users.view'] }
|
||||
const { canAny } = usePermissions()
|
||||
expect(canAny(['core.roles.manage', 'core.users.view'])).toBe(true)
|
||||
expect(canAny(['core.roles.manage', 'core.permissions.view'])).toBe(false)
|
||||
})
|
||||
|
||||
it('canAll retourne true uniquement si tous les codes matchent', () => {
|
||||
mockUser.value = {
|
||||
isAdmin: false,
|
||||
effectivePermissions: ['core.users.view', 'core.roles.view'],
|
||||
}
|
||||
const { canAll } = usePermissions()
|
||||
expect(canAll(['core.users.view', 'core.roles.view'])).toBe(true)
|
||||
expect(canAll(['core.users.view', 'core.roles.manage'])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Composable d'autorisation cote front.
|
||||
*
|
||||
* Source de verite : `useAuthStore().user`, qui porte le payload /api/me
|
||||
* incluant `isAdmin` et `effectivePermissions` (tableau trie sans doublons).
|
||||
*
|
||||
* Regle de bypass dupliquee avec `PermissionVoter` (back) :
|
||||
* si `user.isAdmin === true`, toutes les permissions sont accordees.
|
||||
* Cette duplication est volontaire pour offrir un feedback UI immediat
|
||||
* sans aller-retour serveur. Si la regle de bypass change cote back
|
||||
* (decision architecturale #343 section 11), ce composable DOIT evoluer
|
||||
* en meme temps.
|
||||
*
|
||||
* Stateless : aucun ref module-level, tout passe par Pinia. Le reset est
|
||||
* assure automatiquement par `authStore.logout()` qui efface `user`.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const auth = useAuthStore()
|
||||
|
||||
function can(code: string): boolean {
|
||||
const user = auth.user
|
||||
if (!user) return false
|
||||
if (user.isAdmin) return true
|
||||
return user.effectivePermissions.includes(code)
|
||||
}
|
||||
|
||||
function canAny(codes: string[]): boolean {
|
||||
return codes.some(can)
|
||||
}
|
||||
|
||||
function canAll(codes: string[]): boolean {
|
||||
return codes.every(can)
|
||||
}
|
||||
|
||||
return { can, canAny, canAll }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface Permission {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
module: string
|
||||
orphan: boolean
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
description: string | null
|
||||
isSystem: boolean
|
||||
permissions: (Permission | string)[]
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
directPermissions: string[]
|
||||
}
|
||||
|
||||
export interface EffectivePermission {
|
||||
code: string
|
||||
label: string
|
||||
module: string
|
||||
sources: string[]
|
||||
}
|
||||
@@ -2,8 +2,4 @@ export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
/** Vrai si l'utilisateur a le bypass admin total (voir ticket #343 section 11). */
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
25
makefile
25
makefile
@@ -38,7 +38,7 @@ restart: env-init
|
||||
$(DOCKER_COMPOSE) down
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
|
||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate test-db-setup
|
||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
|
||||
|
||||
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
||||
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
||||
@@ -59,10 +59,6 @@ nuxt-lint:
|
||||
nuxt-lint-fix:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run lint:fix"
|
||||
|
||||
# Lance les tests unitaires frontend (Vitest)
|
||||
nuxt-test:
|
||||
$(EXEC_PHP) sh -c "cd frontend && npm run test"
|
||||
|
||||
delete_built_dir:
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
|
||||
@@ -83,23 +79,9 @@ build-without-cache:
|
||||
migration-migrate:
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||
|
||||
# Cree et initialise la base de test utilisee par PHPUnit
|
||||
# (le suffixe "_test" est applique automatiquement par Doctrine en APP_ENV=test)
|
||||
# Ordre : fixtures -> sync-permissions, car fixtures:load purge la table permission
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
# Synchronise le catalogue de permissions RBAC avec les declarations
|
||||
# des modules actifs (CoreModule::permissions() etc.). Idempotent.
|
||||
sync-permissions:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
@@ -108,8 +90,6 @@ db-reset:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists
|
||||
$(MAKE) migration-migrate
|
||||
$(MAKE) fixtures
|
||||
$(MAKE) sync-permissions
|
||||
$(MAKE) test-db-setup
|
||||
|
||||
# Restart la bdd
|
||||
db-restart:
|
||||
@@ -147,8 +127,5 @@ php-cs-fixer-allow-risky:
|
||||
test:
|
||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||
|
||||
# Lance l'ensemble des tests (PHPUnit back + Vitest front)
|
||||
test-all: test nuxt-test
|
||||
|
||||
wait:
|
||||
sleep 10
|
||||
|
||||
@@ -34,6 +34,7 @@ final class CoreModule
|
||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||
['code' => 'core.permissions.view', 'label' => 'Voir le catalogue des permissions'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.permissions.view')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.permissions.view')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
|
||||
@@ -35,26 +35,31 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
security: "is_granted('core.roles.manage')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
security: "is_granted('core.roles.manage')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('core.roles.manage')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.roles.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
@@ -32,24 +31,25 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('core.users.view')",
|
||||
security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view')
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new GetCollection(
|
||||
security: "is_granted('core.users.view')",
|
||||
security: "is_granted('ROLE_ADMIN')", // TODO ticket #345 : remplacer par is_granted('core.users.view')
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(
|
||||
name: 'user_rbac_patch',
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('core.users.manage')",
|
||||
// TODO ticket #345 : remplacer par is_granted('core.users.manage')
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
normalizationContext: ['groups' => ['user:rbac:read']],
|
||||
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||
processor: UserRbacProcessor::class,
|
||||
),
|
||||
new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
@@ -68,10 +68,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||
// Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac.
|
||||
// Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer
|
||||
// la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName).
|
||||
#[Groups(['user:rbac:write'])]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||
private bool $isAdmin = false;
|
||||
|
||||
/**
|
||||
@@ -172,10 +169,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $roles;
|
||||
}
|
||||
|
||||
// Groupes de lecture + nom serialise explicite pour eviter que Symfony
|
||||
// ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin".
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
|
||||
#[SerializedName('isAdmin')]
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->isAdmin;
|
||||
@@ -252,7 +245,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
#[Groups(['me:read'])]
|
||||
public function getEffectivePermissions(): array
|
||||
{
|
||||
$codes = [];
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
* Il compte les admins restants et leve LastAdminProtectionException si
|
||||
* le seuil minimum (1) serait franchi.
|
||||
*/
|
||||
final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
|
||||
final class AdminHeadcountGuard
|
||||
{
|
||||
public function __construct(private readonly UserRepositoryInterface $userRepository) {}
|
||||
|
||||
@@ -53,13 +53,6 @@ final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
|
||||
* La verification est volontairement conservative (<=1) pour couvrir
|
||||
* le cas defensif ou la base serait deja dans un etat incoherent (0 admin).
|
||||
*
|
||||
* TOCTOU accepte : la verification n'utilise pas de verrou pessimiste
|
||||
* (SELECT ... FOR UPDATE). Deux demotions concurrentes pourraient donc
|
||||
* passer le garde simultanement. Ce risque est accepte dans le contexte
|
||||
* PME/CRM ou les operations d'administration sont rares et mono-operateur.
|
||||
* Si la concurrence admin devient un enjeu, ajouter un verrou pessimiste
|
||||
* sur countAdmins() ou une contrainte CHECK en base.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1
|
||||
*/
|
||||
private function checkAdminHeadcount(): void
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
|
||||
/**
|
||||
* Contrat du gardien de l'invariant "au moins un admin sur l'instance".
|
||||
*
|
||||
* Separer l'interface de l'implementation permet de tester unitairement
|
||||
* les processors qui dependent de ce garde sans instancier le repository.
|
||||
*/
|
||||
interface AdminHeadcountGuardInterface
|
||||
{
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor dedie a l'operation `DELETE /api/users/{id}`.
|
||||
*
|
||||
* Delegue la suppression au RemoveProcessor Doctrine decore apres avoir
|
||||
* applique la garde "dernier admin global" : si l'utilisateur cible est
|
||||
* le seul admin restant sur l'instance, la suppression est refusee pour
|
||||
* preserver l'invariant "au moins un administrateur reste toujours".
|
||||
*
|
||||
* La garde est portee par AdminHeadcountGuard (domaine), partagee avec
|
||||
* UserRbacProcessor qui gere le meme invariant sur le chemin PATCH /rbac.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final class UserProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof User) {
|
||||
// Ce processor est wire exclusivement sur l'operation Delete de User.
|
||||
// Si on arrive ici avec un autre type, c'est une misconfiguration.
|
||||
throw new LogicException(sprintf(
|
||||
'UserProcessor attend une instance de %s, %s recu.',
|
||||
User::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde dernier admin global : on ne verifie que si on supprime
|
||||
// effectivement un admin. La suppression d'un user standard n'a
|
||||
// aucun impact sur le compteur d'administrateurs.
|
||||
if ($data->isAdmin()) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -23,12 +21,14 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
* ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le
|
||||
* UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`.
|
||||
*
|
||||
* Gardes metier (dans l'ordre d'execution) :
|
||||
* Gardes metier :
|
||||
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
|
||||
* Cas particulier plus strict, avec message dedie.
|
||||
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
||||
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
||||
* AdminHeadcountGuardInterface.
|
||||
* On compare l'etat entrant a l'etat d'origine via l'UnitOfWork Doctrine,
|
||||
* en restreignant la verification au couple "user courant == user cible".
|
||||
*
|
||||
* TODO ticket #345 : garde "dernier admin" globale via inventaire des admins
|
||||
* restants (empeche de retirer `isAdmin` au dernier admin de l'instance, meme
|
||||
* si ce n'est pas sa propre operation).
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
@@ -39,7 +39,6 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -57,26 +56,19 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
|
||||
$currentUser = $this->security->getUser();
|
||||
|
||||
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||
$willLoseAdmin = true === $wasAdmin && false === $data->isAdmin();
|
||||
// Garde auto-suicide : l'user courant ne peut pas retirer son propre
|
||||
// flag admin. On ne compare que si la cible == l'user courant.
|
||||
if ($currentUser instanceof User
|
||||
&& null !== $currentUser->getId()
|
||||
&& $currentUser->getId() === $data->getId()
|
||||
) {
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||
|
||||
// Garde auto-suicide : cas particulier plus strict — l'user courant ne
|
||||
// peut pas retirer son propre flag admin, meme si d'autres admins existent.
|
||||
if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) {
|
||||
throw new BadRequestHttpException(
|
||||
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||
);
|
||||
}
|
||||
|
||||
// Garde dernier admin global : invariant general — impossible de retirer
|
||||
// isAdmin si cela laisserait l'instance sans administrateur.
|
||||
if ($willLoseAdmin) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
if (true === $wasAdmin && false === $data->isAdmin()) {
|
||||
throw new BadRequestHttpException(
|
||||
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter RBAC qui evalue les codes de permission metier au format
|
||||
* "module.resource.action" (ex: "core.users.view").
|
||||
*
|
||||
* - Ignore silencieusement les attributs non-RBAC (ROLE_*, IS_AUTHENTICATED_*, ...),
|
||||
* qui restent traites par les voters core de Symfony. Strategy 'affirmative'
|
||||
* par defaut : tant qu'un voter repond GRANTED, l'acces est accorde.
|
||||
* - Bypass total si l'utilisateur porte le flag isAdmin (decision architecturale
|
||||
* gravee au ticket #343 section 11 : is_admin est le seul levier technique
|
||||
* de bypass, jamais remplace par un check de role).
|
||||
* - Sinon, compare l'attribut aux permissions effectives de l'utilisateur
|
||||
* (union dedupliquee triee venant des roles et des permissions directes).
|
||||
*
|
||||
* @extends Voter<string, mixed>
|
||||
*/
|
||||
final class PermissionVoter extends Voter
|
||||
{
|
||||
/**
|
||||
* Regex de reconnaissance des codes de permission.
|
||||
*
|
||||
* Contraintes :
|
||||
* - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_).
|
||||
* - Au moins un point de separation (ecarte les attributs atomiques
|
||||
* type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY).
|
||||
* - Segments en snake_case minuscule coherents avec les permissions
|
||||
* declarees par les *Module::permissions() et validees par app:sync-permissions.
|
||||
*/
|
||||
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
// Token anonyme ou user d'un autre type : on refuse explicitement.
|
||||
// Les voters core (AuthenticatedVoter) se chargent deja du cas
|
||||
// "pas authentifie du tout".
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
// Bypass total : decision architecturale #343 section 11.
|
||||
// Cette regle est dupliquee cote front dans usePermissions()
|
||||
// et les deux doivent bouger ensemble si elle evolue un jour.
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($attribute, $user->getEffectivePermissions(), true);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<object>
|
||||
@@ -17,10 +16,10 @@ class SidebarProvider implements ProviderInterface
|
||||
/** @var list<string> */
|
||||
private readonly array $activeModuleIds;
|
||||
|
||||
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
|
||||
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
|
||||
private readonly array $sidebarConfig;
|
||||
|
||||
public function __construct(private readonly Security $security)
|
||||
public function __construct()
|
||||
{
|
||||
$configDir = dirname(__DIR__, 5).'/config';
|
||||
|
||||
@@ -59,18 +58,6 @@ class SidebarProvider implements ProviderInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrage par permission RBAC : si l'item declare une permission
|
||||
// requise et que l'utilisateur courant ne la possede pas, l'item
|
||||
// est masque et sa route ajoutee aux routes desactivees.
|
||||
$requiredPermission = $item['permission'] ?? null;
|
||||
if (null !== $requiredPermission && !$this->security->isGranted($requiredPermission)) {
|
||||
if (isset($item['to'])) {
|
||||
$disabledRoutes[] = $item['to'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'label' => $item['label'],
|
||||
'to' => $item['to'],
|
||||
|
||||
@@ -6,11 +6,7 @@ namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Classe de base pour les tests fonctionnels API Platform du module Core.
|
||||
@@ -22,9 +18,6 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
* (cookie BEARER HTTP-only pose par lexik_jwt_authentication).
|
||||
* - `getEm()` : recupere l'EntityManager depuis le container courant.
|
||||
* A rappeler apres chaque createClient() car le kernel est reboote.
|
||||
* - `createUserWithPermission()` : cree un user non-admin jetable portant
|
||||
* une permission specifique via un role custom. Utile pour prouver qu'un
|
||||
* non-admin avec la permission obtient 200, et sans la permission 403.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -70,64 +63,4 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un utilisateur non-admin portant une permission specifique via un
|
||||
* role custom jetable. A utiliser dans les tests fonctionnels qui doivent
|
||||
* prouver qu'un non-admin avec la permission requise obtient 200, et
|
||||
* sans la permission obtient 403.
|
||||
*
|
||||
* Le user et le role sont persistes avec un suffixe aleatoire pour eviter
|
||||
* les collisions inter-tests. Le password est "testpass".
|
||||
*
|
||||
* Prerequis : la permission identifiee par $permissionCode doit exister en
|
||||
* base (seeder via `app:sync-permissions`). Si elle est introuvable, le test
|
||||
* echoue immediatement avec un message explicite.
|
||||
*
|
||||
* @param string $permissionCode Le code de la permission (ex: "core.users.view")
|
||||
*
|
||||
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
|
||||
*/
|
||||
protected function createUserWithPermission(string $permissionCode): array
|
||||
{
|
||||
if (!self::$kernel) {
|
||||
self::bootKernel();
|
||||
}
|
||||
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|Permission $permission */
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
|
||||
|
||||
self::assertNotNull(
|
||||
$permission,
|
||||
sprintf(
|
||||
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
|
||||
$permissionCode,
|
||||
),
|
||||
);
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = 'testuser_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
||||
$role->addPermission($permission);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
$em->persist($user);
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return ['username' => $username, 'password' => $password];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'endpoint GET /api/me.
|
||||
*
|
||||
* Verifie que la reponse inclut `isAdmin` et `effectivePermissions`
|
||||
* dans le groupe de serialisation `me:read`.
|
||||
*
|
||||
* Strategie de donnees :
|
||||
* - Les tests 1-3 s'appuient exclusivement sur les fixtures (admin/alice).
|
||||
* - Le test 4 cree un user jetable prefixe `test_me_` + role + permission,
|
||||
* purges en tearDown.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class MeApiTest extends AbstractApiTestCase
|
||||
{
|
||||
private const TEST_USER_PREFIX = 'test_me_';
|
||||
private const TEST_ROLE_PREFIX = 'test_me_';
|
||||
private const TEST_PERMISSION_PREFIX = 'test.me.';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* L'admin (isAdmin=true, role systeme sans permission explicite) doit
|
||||
* obtenir un payload /me avec isAdmin=true et effectivePermissions=[].
|
||||
*/
|
||||
public function testMeEndpointReturnsIsAdminAndEffectivePermissionsForAdmin(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertSame('admin', $data['username'], 'Le champ username doit etre "admin".');
|
||||
self::assertTrue($data['isAdmin'], 'isAdmin doit etre true pour l\'admin fixture.');
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertIsArray($data['effectivePermissions'], 'effectivePermissions doit etre un tableau JSON.');
|
||||
// Le role systeme admin n'a pas de permissions explicites : tableau vide attendu.
|
||||
self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour l\'admin sans permissions explicites.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un utilisateur standard (isAdmin=false, role user sans permission) doit
|
||||
* obtenir isAdmin=false et effectivePermissions=[].
|
||||
*/
|
||||
public function testMeEndpointReturnsEmptyPermissionsForStandardUser(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertFalse($data['isAdmin'], 'isAdmin doit etre false pour alice.');
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertSame([], $data['effectivePermissions'], 'effectivePermissions doit etre [] pour un user sans role avec permission.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Une requete non authentifiee sur /api/me doit retourner 401.
|
||||
*/
|
||||
public function testMeEndpointRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un user rattache a un role portant la permission `core.users.view` doit
|
||||
* retrouver cette permission dans effectivePermissions, triee alphabetiquement.
|
||||
*/
|
||||
public function testMeEndpointReturnsEffectivePermissionsForUserWithRolePermissions(): void
|
||||
{
|
||||
// --- Preparation des donnees de test ---
|
||||
self::bootKernel();
|
||||
$em = $this->getEm();
|
||||
|
||||
$this->cleanupTestData();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$permission = new Permission('test.me.core.users.view', 'View users (test me)', 'core');
|
||||
$em->persist($permission);
|
||||
|
||||
$role = new Role('test_me_viewer', 'Viewer (test me)', false);
|
||||
$role->addPermission($permission);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('test_me_viewer_user');
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, 'secret'));
|
||||
$user->addRbacRole($role);
|
||||
$em->persist($user);
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
// --- Appel API ---
|
||||
$client = $this->authenticatedClient('test_me_viewer_user', 'secret');
|
||||
$response = $client->request('GET', '/api/me', [
|
||||
'headers' => ['Accept' => 'application/ld+json'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertArrayHasKey('effectivePermissions', $data, 'effectivePermissions doit etre present dans le payload.');
|
||||
self::assertContains(
|
||||
'test.me.core.users.view',
|
||||
$data['effectivePermissions'],
|
||||
'effectivePermissions doit contenir le code de permission du role attribue.',
|
||||
);
|
||||
|
||||
// Verifie le tri alphabetique (contrat spec section 9 ticket-343).
|
||||
$sorted = $data['effectivePermissions'];
|
||||
$copy = $sorted;
|
||||
sort($copy);
|
||||
self::assertSame($copy, $sorted, 'effectivePermissions doit etre trie alphabetiquement.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entites de test creees par les methodes ci-dessus.
|
||||
* Ordre : users d'abord (FK vers roles), puis roles, puis permissions.
|
||||
*/
|
||||
private function cleanupTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_PERMISSION_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'exposition API Platform de l'entite Permission.
|
||||
@@ -166,42 +164,17 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testStandardUserCanListPermissions(): void
|
||||
public function testNonAdminReturns403(): void
|
||||
{
|
||||
// Le catalogue de permissions est accessible a tout utilisateur authentifie.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testStandardUserCanGetPermission(): void
|
||||
{
|
||||
$permission = $this->getEm()->getRepository(Permission::class)
|
||||
->findOneBy(['code' => 'test.core.users.view'])
|
||||
;
|
||||
self::assertNotNull($permission);
|
||||
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/permissions/'.$permission->getId());
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function cleanupTestPermissions(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Purge des users et roles jetables crees par createUserWithPermission().
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', 'testuser_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
$this->getEm()->createQuery(
|
||||
'DELETE FROM '.Permission::class.' p WHERE p.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_CODE_PREFIX.'%')->execute();
|
||||
}
|
||||
|
||||
@@ -368,85 +368,6 @@ final class RoleApiTest extends AbstractApiTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
||||
|
||||
public function testListRolesAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.roles.view doit pouvoir lister les roles.
|
||||
$credentials = $this->createUserWithPermission('core.roles.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testListRolesAsUserWithOnlyManagePermissionReturns403(): void
|
||||
{
|
||||
// Un user avec uniquement core.roles.manage ne peut PAS lister (list/get
|
||||
// exige core.roles.view, cf. spec section 3 ticket-345).
|
||||
$credentials = $this->createUserWithPermission('core.roles.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testListRolesAsStandardUserReturns403(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsUserWithManagePermissionReturns201(): void
|
||||
{
|
||||
// Un non-admin portant core.roles.manage doit pouvoir creer un role.
|
||||
$credentials = $this->createUserWithPermission('core.roles.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$response = $client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_created_by_manager',
|
||||
'label' => 'Role cree par manager (test)',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
self::assertSame('test_created_by_manager', $data['code']);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsUserWithOnlyViewPermissionReturns403(): void
|
||||
{
|
||||
// Un user avec core.roles.view uniquement ne peut pas creer (POST exige .manage).
|
||||
$credentials = $this->createUserWithPermission('core.roles.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_shouldnotcreate',
|
||||
'label' => 'Ne doit pas etre cree',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCreateRoleAsStandardUserReturns403(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('POST', '/api/roles', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'code' => 'test_shouldnotcreate_alice',
|
||||
'label' => 'Ne doit pas etre cree',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les donnees de test (roles et permissions prefixees `test.`).
|
||||
* Ne touche JAMAIS aux roles systeme `admin` et `user` charges par les
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'exposition API Platform de l'entite User.
|
||||
*
|
||||
* Strategie :
|
||||
* - Les fixtures chargent 3 users : admin (is_admin=true), alice, bob.
|
||||
* - Les tests de lecture s'appuient sur les fixtures sans les modifier.
|
||||
* - Les tests de suppression et de guard "dernier admin" creent des users
|
||||
* additionnels via EntityManager, purges en tearDown.
|
||||
* - On ne supprime JAMAIS les users fixture (admin / alice / bob).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class UserApiTest extends AbstractApiTestCase
|
||||
{
|
||||
private const TEST_USER_PREFIX = 'test_';
|
||||
private const TEST_ROLE_PREFIX = 'test_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// --- Tests lecture collection ---
|
||||
|
||||
public function testListUsersAsAdminReturns200(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('member', $data);
|
||||
// Au moins 3 users fixture.
|
||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
||||
}
|
||||
|
||||
public function testListUsersAsUserWithViewPermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.users.view doit pouvoir lister les users.
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testListUsersAsStandardUserReturns403(): void
|
||||
{
|
||||
// alice n'a aucune permission RBAC : acces refuse.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('GET', '/api/users');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// --- Tests suppression ---
|
||||
|
||||
public function testDeleteNonAdminUserAsAdminReturns204(): void
|
||||
{
|
||||
// Confirme que la suppression d'un user non-admin fonctionne.
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$target = new User();
|
||||
$target->setUsername('test_deletable_user');
|
||||
$target->setIsAdmin(false);
|
||||
$target->setPassword($hasher->hashPassword($target, 'secret'));
|
||||
$em->persist($target);
|
||||
$em->flush();
|
||||
$targetId = $target->getId();
|
||||
$em->clear();
|
||||
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$client->request('DELETE', '/api/users/'.$targetId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// Verification cote base : le user n'existe plus.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNull($em->getRepository(User::class)->find($targetId));
|
||||
}
|
||||
|
||||
public function testDeleteSecondAdminReturns204(): void
|
||||
{
|
||||
// Quand il y a 2 admins, supprimer le second est autorise (garde non declenchee).
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$secondAdmin = new User();
|
||||
$secondAdmin->setUsername('test_second_admin');
|
||||
$secondAdmin->setIsAdmin(true);
|
||||
$secondAdmin->setPassword($hasher->hashPassword($secondAdmin, 'secret'));
|
||||
$em->persist($secondAdmin);
|
||||
$em->flush();
|
||||
$secondAdminId = $secondAdmin->getId();
|
||||
$em->clear();
|
||||
|
||||
// Auth en tant qu'admin fixture, supprime le second admin.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$client->request('DELETE', '/api/users/'.$secondAdminId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNull($em->getRepository(User::class)->find($secondAdminId));
|
||||
}
|
||||
|
||||
public function testDeleteLastAdminReturns400(): void
|
||||
{
|
||||
// Scenario "dernier admin global" : un seul admin existe (fixture admin).
|
||||
// Il tente de se supprimer lui-meme -> garde activee -> 400.
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|User $fixtureAdmin */
|
||||
$fixtureAdmin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||
self::assertNotNull($fixtureAdmin, 'L\'user admin fixture doit exister.');
|
||||
$fixtureAdminId = $fixtureAdmin->getId();
|
||||
|
||||
// Garantit qu'il n'y a qu'un seul admin au moment du test :
|
||||
// s'assure que test_second_admin n'existe pas (tearDown le purge, mais
|
||||
// soyons defensifs si un test precedent n'a pas nettoye).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix AND u.username != :admin'
|
||||
)->setParameters(['prefix' => 'test_%', 'admin' => 'admin'])->execute();
|
||||
|
||||
// Auth en tant que l'admin fixture et tente l'auto-suppression.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('DELETE', '/api/users/'.$fixtureAdminId);
|
||||
|
||||
self::assertResponseStatusCodeSame(400);
|
||||
|
||||
// Verification cote base : l'admin fixture doit toujours exister.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
self::assertNotNull(
|
||||
$em->getRepository(User::class)->find($fixtureAdminId),
|
||||
'Le dernier admin ne doit PAS etre supprime.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteAsStandardUserReturns403(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
/** @var null|User $alice */
|
||||
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
self::assertNotNull($alice);
|
||||
|
||||
/** @var null|User $bob */
|
||||
$bob = $em->getRepository(User::class)->findOneBy(['username' => 'bob']);
|
||||
self::assertNotNull($bob);
|
||||
|
||||
// alice sans permission ne peut pas supprimer bob.
|
||||
$client = $this->authenticatedClient('alice', 'alice');
|
||||
$client->request('DELETE', '/api/users/'.$bob->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge les entites de test creees par cette suite.
|
||||
* Ne touche JAMAIS aux fixtures (admin / alice / bob).
|
||||
*/
|
||||
private function cleanupTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Purge des users jetables crees par les tests (y compris testuser_ de createUserWithPermission).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
// Purge des roles jetables crees par createUserWithPermission.
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -224,40 +224,6 @@ final class UserRbacApiTest extends AbstractApiTestCase
|
||||
self::assertFalse($reloaded->isAdmin());
|
||||
}
|
||||
|
||||
// --- Tests voter RBAC : non-admin avec / sans permission ---
|
||||
|
||||
public function testPatchRbacAsUserWithManagePermissionReturns200(): void
|
||||
{
|
||||
// Un non-admin portant core.users.manage doit pouvoir appeler PATCH /rbac.
|
||||
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||
self::assertNotNull($target);
|
||||
|
||||
$credentials = $this->createUserWithPermission('core.users.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isAdmin' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testPatchRbacAsUserWithOnlyViewPermissionReturns403(): void
|
||||
{
|
||||
// Un user avec core.users.view uniquement ne peut pas ecrire via /rbac.
|
||||
$target = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'test_target']);
|
||||
self::assertNotNull($target);
|
||||
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
$client->request('PATCH', '/api/users/'.$target->getId().'/rbac', [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isAdmin' => true],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testPatchRbacSelfRemovingAdminReturns400(): void
|
||||
{
|
||||
// On utilise le user admin dedie (test_self_admin) pour ne pas
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use LogicException;
|
||||
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserProcessor : couvre la garde "dernier admin global"
|
||||
* et la delegation au RemoveProcessor Doctrine decore pour l'operation DELETE.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
#[AllowMockObjectsWithoutExpectations]
|
||||
final class UserProcessorTest extends TestCase
|
||||
{
|
||||
private MockObject&ProcessorInterface $removeProcessor;
|
||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
private UserProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->removeProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
|
||||
$this->processor = new UserProcessor(
|
||||
$this->removeProcessor,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenUserIsNotAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('alice');
|
||||
$user->setIsAdmin(false);
|
||||
|
||||
// La garde ne doit jamais etre appellee pour un non-admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($user, new Delete());
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testDelegatesWhenAdminButNotLast(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
// La garde est appelee et ne leve pas d'exception (il reste d'autres admins).
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
;
|
||||
|
||||
$this->removeProcessor
|
||||
->expects($this->once())
|
||||
->method('process')
|
||||
->with($user)
|
||||
->willReturn(null)
|
||||
;
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testBlocksWhenDeletingLastAdmin(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('admin');
|
||||
$user->setIsAdmin(true);
|
||||
|
||||
$exceptionMessage = 'Impossible : au moins un administrateur doit rester sur l\'instance.';
|
||||
|
||||
$this->adminHeadcountGuard
|
||||
->expects($this->once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDeletion')
|
||||
->with($user)
|
||||
->willThrowException(new LastAdminProtectionException($exceptionMessage))
|
||||
;
|
||||
|
||||
// La suppression ne doit pas etre executee si la garde echoue.
|
||||
$this->removeProcessor
|
||||
->expects($this->never())
|
||||
->method('process')
|
||||
;
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage($exceptionMessage);
|
||||
|
||||
$this->processor->process($user, new Delete());
|
||||
}
|
||||
|
||||
public function testFailFastOnInvalidDataType(): void
|
||||
{
|
||||
// Garde-fou contre une misconfiguration : ce processor est wire
|
||||
// exclusivement sur l'operation Delete de User.
|
||||
$this->adminHeadcountGuard->expects($this->never())->method('ensureAtLeastOneAdminRemainsAfterDeletion');
|
||||
$this->removeProcessor->expects($this->never())->method('process');
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('UserProcessor attend une instance de');
|
||||
|
||||
$this->processor->process(new stdClass(), new Delete());
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
@@ -24,9 +22,9 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide", la
|
||||
* garde "dernier admin global" et la delegation au PersistProcessor Doctrine
|
||||
* decore pour les trois champs RBAC (isAdmin, roles, directPermissions).
|
||||
* Tests unitaires du UserRbacProcessor : couvre la garde "auto-suicide" et la
|
||||
* delegation au PersistProcessor Doctrine decore pour les trois champs RBAC
|
||||
* (isAdmin, roles, directPermissions).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,16 +35,14 @@ final class UserRbacProcessorTest extends TestCase
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private MockObject&UnitOfWork $unitOfWork;
|
||||
private MockObject&Security $security;
|
||||
private AdminHeadcountGuardInterface&MockObject $adminHeadcountGuard;
|
||||
private UserRbacProcessor $processor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
$this->security = $this->createMock(Security::class);
|
||||
$this->adminHeadcountGuard = $this->createMock(AdminHeadcountGuardInterface::class);
|
||||
$this->persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->unitOfWork = $this->createMock(UnitOfWork::class);
|
||||
$this->security = $this->createMock(Security::class);
|
||||
|
||||
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
|
||||
|
||||
@@ -54,28 +50,19 @@ final class UserRbacProcessorTest extends TestCase
|
||||
$this->persistProcessor,
|
||||
$this->entityManager,
|
||||
$this->security,
|
||||
$this->adminHeadcountGuard,
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchPromotesUserToAdminDelegatesToPersistProcessor(): void
|
||||
{
|
||||
$target = $this->buildUser(42, 'alice', true);
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$target->setIsAdmin(true);
|
||||
|
||||
$currentAdmin = $this->buildUser(1, 'admin', true);
|
||||
$this->security->method('getUser')->willReturn($currentAdmin);
|
||||
|
||||
// La cible gagne isAdmin (false -> true) : willLoseAdmin = false, donc
|
||||
// getOriginalEntityData est appele mais aucune garde ne bloque.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
// Cible != user courant : pas de lecture d'UnitOfWork necessaire.
|
||||
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
@@ -159,30 +146,14 @@ final class UserRbacProcessorTest extends TestCase
|
||||
|
||||
public function testPatchAdminDemotingAnotherUserIsAllowed(): void
|
||||
{
|
||||
// Un admin qui retire isAdmin a quelqu'un d'autre : autorise si d'autres
|
||||
// admins existent (guard ne leve pas d'exception).
|
||||
// Un admin qui retire isAdmin a quelqu'un d'autre : autorise.
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
// La cible perd isAdmin (true -> false) : getOriginalEntityData est appele.
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : d'autres admins existent.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($target)
|
||||
;
|
||||
// Cible != user courant : pas de verification d'auto-suicide.
|
||||
$this->unitOfWork->expects(self::never())->method('getOriginalEntityData');
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
@@ -239,150 +210,6 @@ final class UserRbacProcessorTest extends TestCase
|
||||
$this->processor->process(new stdClass(), new Patch());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tests de la garde "dernier admin global"
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function testBlocksDemotionWhenLastAdminGlobally(): void
|
||||
{
|
||||
// L'admin courant A tente de retirer isAdmin a l'admin B (le dernier).
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde signale qu'il n'y aurait plus aucun admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
->willThrowException(new LastAdminProtectionException())
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Impossible : au moins un administrateur doit rester sur l\'instance.');
|
||||
|
||||
$this->processor->process($adminB, new Patch());
|
||||
}
|
||||
|
||||
public function testDelegatesDemotionWhenAdminsRemain(): void
|
||||
{
|
||||
// L'admin courant A retire isAdmin a l'admin B, mais d'autres admins existent.
|
||||
$adminA = $this->buildUser(1, 'adminA', true);
|
||||
$adminB = $this->buildUser(2, 'adminB', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($adminA);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($adminB)
|
||||
->willReturn([
|
||||
'id' => 2,
|
||||
'username' => 'adminB',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// Le garde ne leve pas d'exception : il reste au moins un admin.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::once())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
->with($adminB)
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($adminB)
|
||||
->willReturn($adminB)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($adminB, new Patch());
|
||||
|
||||
self::assertSame($adminB, $result);
|
||||
}
|
||||
|
||||
public function testDoesNotCallGuardWhenIsAdminUntouched(): void
|
||||
{
|
||||
// PATCH qui ne touche pas isAdmin (reste false) : la garde ne doit pas etre appelee.
|
||||
$target = $this->buildUser(42, 'alice', false);
|
||||
$current = $this->buildUser(1, 'admin', true);
|
||||
|
||||
$this->security->method('getUser')->willReturn($current);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($target)
|
||||
->willReturn([
|
||||
'id' => 42,
|
||||
'username' => 'alice',
|
||||
'isAdmin' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// isAdmin reste false : willLoseAdmin = false, garde jamais appelee.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$this->persistProcessor
|
||||
->expects(self::once())
|
||||
->method('process')
|
||||
->with($target)
|
||||
->willReturn($target)
|
||||
;
|
||||
|
||||
$result = $this->processor->process($target, new Patch());
|
||||
|
||||
self::assertSame($target, $result);
|
||||
}
|
||||
|
||||
public function testAutoSuicideTakesPrecedenceOverLastAdminGlobal(): void
|
||||
{
|
||||
// L'unique admin tente de se retirer lui-meme son propre flag.
|
||||
// La garde auto-suicide doit court-circuiter avant la garde dernier-admin.
|
||||
$self = $this->buildUser(1, 'admin', false); // isAdmin -> false dans le PATCH
|
||||
|
||||
$this->security->method('getUser')->willReturn($self);
|
||||
|
||||
$this->unitOfWork
|
||||
->method('getOriginalEntityData')
|
||||
->with($self)
|
||||
->willReturn([
|
||||
'id' => 1,
|
||||
'username' => 'admin',
|
||||
'isAdmin' => true,
|
||||
])
|
||||
;
|
||||
|
||||
// La garde dernier-admin ne doit jamais etre appelee : l'auto-suicide
|
||||
// court-circuite avant.
|
||||
$this->adminHeadcountGuard
|
||||
->expects(self::never())
|
||||
->method('ensureAtLeastOneAdminRemainsAfterDemotion')
|
||||
;
|
||||
|
||||
$this->persistProcessor->expects(self::never())->method('process');
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Vous ne pouvez pas retirer vos propres droits administrateur.');
|
||||
|
||||
$this->processor->process($self, new Patch());
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un User avec un id force via reflection (les mocks
|
||||
* d'UnitOfWork n'alimentent pas l'id tout seul).
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Infrastructure\Security\PermissionVoter;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* Tests unitaires du PermissionVoter RBAC.
|
||||
*
|
||||
* Le voter est teste via sa methode publique vote() qui retourne une des
|
||||
* trois constantes VoterInterface : ACCESS_GRANTED, ACCESS_DENIED, ACCESS_ABSTAIN.
|
||||
* - ACCESS_ABSTAIN : supports() a retourne false (attribut non-RBAC).
|
||||
* - ACCESS_GRANTED / ACCESS_DENIED : voteOnAttribute() a ete invoque.
|
||||
*
|
||||
* Aucun acces base de donnees : toutes les entites sont construites en memoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PermissionVoterTest extends TestCase
|
||||
{
|
||||
private PermissionVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new PermissionVoter();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Abstention : attributs non-RBAC
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur ROLE_ADMIN : commence par une majuscule,
|
||||
* ne correspond pas au pattern snake_case minuscule avec point.
|
||||
*/
|
||||
public function testAbstainsOnRoleAdminAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['ROLE_ADMIN']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur IS_AUTHENTICATED_FULLY : contient des majuscules,
|
||||
* pas de point de separation conforme au pattern RBAC.
|
||||
*/
|
||||
public function testAbstainsOnIsAuthenticatedAttribute(): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['IS_AUTHENTICATED_FULLY']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le voter s'abstient sur des attributs malformes : sans point ou avec
|
||||
* majuscules.
|
||||
*/
|
||||
#[DataProvider('malformedAttributeProvider')]
|
||||
public function testAbstainsOnMalformedAttribute(string $attribute): void
|
||||
{
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, [$attribute]);
|
||||
|
||||
$this->assertSame(
|
||||
VoterInterface::ACCESS_ABSTAIN,
|
||||
$result,
|
||||
sprintf('Le voter aurait du s\'abstenir pour l\'attribut "%s".', $attribute),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function malformedAttributeProvider(): array
|
||||
{
|
||||
return [
|
||||
'sans point' => ['nodot'],
|
||||
'majuscule milieu' => ['HAS.UPPERCASE'],
|
||||
'commence chiffre' => ['1core.users.view'],
|
||||
'chaine vide' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Refus : utilisateur non reconnu
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand le token ne porte pas une instance de User metier
|
||||
* (ex: InMemoryUser de Symfony).
|
||||
*/
|
||||
public function testDeniesWhenUserIsNotAUserEntity(): void
|
||||
{
|
||||
$inMemoryUser = new InMemoryUser('anonymous', null, ['ROLE_USER']);
|
||||
$token = new UsernamePasswordToken($inMemoryUser, 'main', $inMemoryUser->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bypass admin
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces systematiquement a un administrateur, meme sans aucune
|
||||
* permission explicite assignee.
|
||||
*/
|
||||
public function testGrantsForAdminBypass(): void
|
||||
{
|
||||
// Admin sans role ni permission directe : le bypass doit suffire.
|
||||
$user = $this->buildUser(username: 'admin', isAdmin: true);
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions effectives via role
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces quand l'utilisateur possede la permission exacte via un role.
|
||||
*/
|
||||
public function testGrantsWhenUserHasExactPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse l'acces quand l'utilisateur possede une permission differente de
|
||||
* celle demandee.
|
||||
*/
|
||||
public function testDeniesWhenUserLacksPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
$role = new Role('viewer', 'Viewer');
|
||||
$role->addPermission($permission);
|
||||
|
||||
$user = $this->buildUser(username: 'alice', isAdmin: false);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
// L'utilisateur a core.users.view mais pas core.roles.manage.
|
||||
$result = $this->voter->vote($token, null, ['core.roles.manage']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Permissions directes (hors roles)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Accorde l'acces via une permission directe (assignee sans passer par un role).
|
||||
*/
|
||||
public function testGrantsForDirectPermission(): void
|
||||
{
|
||||
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
|
||||
|
||||
$user = $this->buildUser(username: 'bob', isAdmin: false);
|
||||
$user->addDirectPermission($permission);
|
||||
|
||||
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||||
|
||||
$result = $this->voter->vote($token, null, ['core.users.view']);
|
||||
|
||||
$this->assertSame(VoterInterface::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construit un User metier minimal sans persistance.
|
||||
*/
|
||||
private function buildUser(string $username, bool $isAdmin): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin($isAdmin);
|
||||
// Mot de passe factice pour satisfaire PasswordAuthenticatedUserInterface.
|
||||
$user->setPassword('hashed_placeholder');
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user