Compare commits

..

28 Commits

Author SHA1 Message Date
Matthieu
4325b1d8a0 feat(core) : RBAC #345 - AdminHeadcountGuard domain service 2026-04-15 15:45:55 +02:00
Matthieu
b7aa445cef feat(core) : RBAC #345 - add core.roles.view permission 2026-04-15 15:42:42 +02:00
Matthieu
fd4ed25c63 docs(core) : RBAC #345 - spec voter + usePermissions 2026-04-15 15:28:51 +02:00
Matthieu
0ccbc70f27 fix(core) : RBAC #344 - ferme leak user list + test cascade delete role 2026-04-15 14:53:49 +02:00
Matthieu
534bdbccdd refactor(core) : RBAC #344 - polish review - narrow rbac read group + fail-fast processors 2026-04-15 14:28:02 +02:00
Matthieu
3c7dc88fe7 feat(core) : RBAC #344 - UserRbacProcessor + endpoint /users/{id}/rbac
Ajoute une operation Patch dediee `PATCH /api/users/{id}/rbac` (nom
`user_rbac_patch`) qui accepte exclusivement les champs RBAC isAdmin,
roles et directPermissions via le groupe user:rbac:write. L'endpoint est
separe volontairement du Patch profil existant pour isoler la modification
des droits de celle des donnees profil (decision 0fc4e16).

UserRbacProcessor delegue au PersistProcessor Doctrine decore et applique
une garde auto-suicide : un admin ne peut pas retirer ses propres droits
administrateur (compare l'etat entrant a l'etat UnitOfWork). La garde
'dernier admin' globale est reportee au ticket #345.

La propriete Doctrine $roles est renommee $rbacRoles pour eviter la
collision avec UserInterface::getRoles() (qui renvoie list<string>) lors
de la normalization API Platform. La cle JSON reste `roles` grace a
SerializedName, le contrat API est inchange.

Tests : 6 unitaires (UserRbacProcessorTest) + 8 fonctionnels
(UserRbacApiTest) couvrant promotion admin, remplacement des collections
roles/directPermissions, 401/403, filtrage du groupe denormalization
(`username` ignore), preservation de isAdmin sur le Patch profil, et
garde auto-suicide.
2026-04-15 14:17:18 +02:00
Matthieu
168a47f2b8 refactor(test) : RBAC #344 - AbstractApiTestCase pour mutualiser auth JWT
Extrait l'helper authenticatedClient(), $alwaysBootKernel et getEm() dans
une classe de base commune aux tests fonctionnels API Platform du module
Core. Supprime la duplication entre PermissionApiTest et RoleApiTest
(flaggee en code review de la Task 2). Prepare le terrain pour le nouveau
UserRbacApiTest introduit avec la Task 4.
2026-04-15 12:14:20 +02:00
Matthieu
87aa1d0b04 test(core) : RBAC #344 - renforce docblock setCode + assertion message exception 2026-04-15 12:05:26 +02:00
Matthieu
d527fbe2d1 feat(core) : RBAC #344 - RoleProcessor + gardes systeme et code immuable 2026-04-15 11:58:37 +02:00
Matthieu
efc12c8bdb fix(test) : RBAC #344 - role test cleanup + SystemRoles constant + assertion seuil 2026-04-15 11:53:01 +02:00
Matthieu
7be0260b29 feat(core) : RBAC #344 - API Platform Role CRUD nominal + validators 2026-04-15 11:41:21 +02:00
Matthieu
f79f061131 fix(test) : RBAC #344 - corrige EM stale et ajoute cas orphan=true 2026-04-15 11:15:41 +02:00
Matthieu
fdb7aded82 feat(core) : RBAC #344 - API Platform Permission en lecture seule
- Expose l'entite Permission via ApiResource (GetCollection + Get uniquement)
- Serialisation limitee au groupe permission:read (id, code, label, module, orphan)
- Securite temporaire is_granted('ROLE_ADMIN'), a remplacer par
  is_granted('core.permissions.view') au ticket #345
- Filtres : SearchFilter exact sur module, BooleanFilter sur orphan
- Configure api_platform.mapping.paths pour que le compile pass AP decouvre
  les ApiResource/ApiFilter declares dans src/Module/Core/Domain/Entity
- Ajoute symfony/browser-kit et symfony/http-client en dev pour les tests
  fonctionnels API Platform, plus KERNEL_CLASS dans phpunit.dist.xml
- Tests fonctionnels PermissionApiTest : collection, get item, filtres
  module et orphan, 405 sur POST, 401 non authentifie, 403 non-admin
2026-04-15 11:03:22 +02:00
Matthieu
1cf550721b docs(rbac) : spec ticket #344 - API CRUD roles & permissions 2026-04-15 10:31:10 +02:00
Matthieu
46fa7d17ae chore(core) : merge RBAC ticket #343 + fix user:write sensibles (PR #2)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-15 10:30:59 +02:00
Matthieu
0fc4e1651b fix(core) : retire user:write des champs RBAC sensibles du User
isAdmin, roles et directPermissions ne doivent pas etre modifiables via
PATCH /api/users/{id}. L exposition en ecriture sera traitee par un
processor dedie dans le ticket #344 (spec section 2 OUT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:15:43 +02:00
Matthieu
d8bda517f9 docs : ajoute note delegation Codex pour taches mecaniques 2026-04-15 08:12:17 +02:00
Matthieu
7ccc913862 docs : exception CLAUDE.md pour les migrations multi-namespace
Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de
version timestamp avec plusieurs migrations_paths) et la regle
provisoire : migrations d'init au namespace racine, namespace
modulaire reserve aux migrations applicatives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:25:26 +02:00
Matthieu
eb0b49a7ef fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine
Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine
Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations
dans l'ordre (namespace, version) et non (version, namespace). Le
Version20260414150034 sous 'App\Module\Core\...' passait donc avant
Version20260407095546 sous 'DoctrineMigrations', provoquant un
"relation user does not exist".

Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations).
Le chemin modulaire reste configure pour les futurs modules, mais
la migration RBAC d'initialisation vit a la racine pour que
'make db-reset' fonctionne en one-shot.

Smoke test end-to-end valide :
- db-reset + fixtures : admin (is_admin=t, role admin), alice/bob
  (is_admin=f, role user)
- app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run
- User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER']
  pour alice/bob
- User::getEffectivePermissions() : union triee des permissions via roles

Ticket #343 - 7/7 : smoke test end-to-end OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:21:43 +02:00
Matthieu
0a496f34e0 fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes
ensureSystemRole() recopie desormais la description depuis la migration
RBAC pour que les chemins prod (migration) et dev (fixtures) produisent
un etat identique.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:15:23 +02:00
Matthieu
aafe08b6ad feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme
- AppFixtures : rattachement des users aux entites Role via
  RoleRepositoryInterface. Re-seed idempotent des roles systeme dans
  ensureSystemRole() pour compenser le purger Doctrine qui vide la table
  role avant load(), afin que "make db-reset && make fixtures" reste un
  workflow one-shot.
- CreateUserCommand : flag --admin attache au role systeme admin + is_admin,
  sinon au role user. Gestion d'erreur explicite si les roles systeme sont
  absents (FAILURE + message pointant vers la migration).
- CreateUserCommand devient final, descriptions traduites en francais.

Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:12:09 +02:00
Matthieu
d68aa0456a feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles
- Nouvelles tables permission, role, role_permission, user_role, user_permission
- Ajout user.is_admin (BOOLEAN, default false)
- Seed des roles systeme admin et user via SQL brut (autonome, pas besoin
  de fixtures pour cette etape)
- Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis
  rattachement user_role selon admin/user
- Drop user.roles en dernier (apres la migration de donnees)
- down() recree la colonne roles et la rehydrate depuis is_admin

Ticket #343 - 5/7 : persistance + migration donnees safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:02:26 +02:00
Matthieu
3b1f18b0e0 feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand
- CoreModule declare 4 permissions initiales (users.view/manage, roles.manage,
  permissions.view)
- Nouvelle commande app:sync-permissions :
  * scan des *Module::permissions() via config/modules.php
  * validation stricte : cles [code, label], prefixe module, non-vides
  * upsert transactionnel non-destructif
  * revival des permissions orphelines qui reapparaissent
  * marquage orphan pour les permissions disparues du code
  * un seul flush() final (evite le flush-par-save de la repo save())

Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:50 +02:00
Matthieu
7aa32b1972 feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes)
- Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5)
- Ajout is_admin bool (seul levier de bypass RBAC via getRoles())
- Ajout ManyToMany User-Role (EAGER, table user_role)
- Ajout ManyToMany User-Permission directes (EAGER, table user_permission)
- getEffectivePermissions() : union dedupliquee triee, utilisee par le
  futur PermissionVoter (#345)
- getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony
- Tests unitaires couvrant derivation getRoles, union, deduplication, tri

Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel.
Fetch EAGER documente : evite le lazy-load au refresh JWT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:48:49 +02:00
Matthieu
3b34d00872 feat(core) : RBAC Task 2 - repositories Permission et Role
- PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync
  command et le futur PermissionVoter)
- RoleRepositoryInterface avec findByCode
- Implementations Doctrine alignees sur DoctrineUserRepository
- Alias DI dans config/services.yaml
- Rebranchement de repositoryClass sur les entites Permission et Role

Ticket #343 - 2/7 : couche persistence RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:40:44 +02:00
Matthieu
0fc0b57e37 refactor(core) : RBAC Task 1 - polish apres revue qualite
- Permission : guards constructeur (code/label/module non vides, code avec point)
- Permission::revive() reutilise updateMetadata() pour eviter la duplication
- Suppression de SystemRolesTest (tautologique, ne capture aucun comportement)
- Role::permissions : commentaire explicite sur la raison du fetch EAGER
- Alignement des types de retour sur static (style User.php)
- Nouveau test Role::addPermission avec permissions distinctes

Ticket #343 - Task 1 polish (revue qualite).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:53 +02:00
Matthieu
f0ea9201f5 feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite
- Entite Permission avec methodes markOrphan/revive/updateMetadata
- Entite Role avec addPermission/removePermission/ensureDeletable
- Constantes SystemRoles (codes admin/user partages)
- Exception SystemRoleDeletionException pour la garde de suppression
- Tests unitaires couvrant le comportement domaine (pas de BDD)

Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence).
Les entites ne portent pas encore repositoryClass (ajoute en Task 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:30:15 +02:00
Matthieu
e3025bf2c9 docs(rbac) : plan et spec ticket #343 + conventions permissions
- Spec detaillee des fondations RBAC backend (entites Role/Permission, sync
  command, migration, fixtures, tests) dans docs/rbac/ticket-343-spec.md
- Ajout CLAUDE.md des regles projet : commentaires francais (PHP + TS/Vue)
  et convention de nommage des permissions module.resource[.sub].action

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:26:49 +02:00
37 changed files with 225 additions and 4862 deletions

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.31'
app.version: '0.1.29'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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[]
}

View File

@@ -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[]
}

View File

@@ -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)),
},
},
})

View File

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

View File

@@ -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'],
];
}
}

View File

@@ -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')",
),
],
)]

View File

@@ -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,
),
],

View File

@@ -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 = [];

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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.'
);
}
}

View File

@@ -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);
}
}

View File

@@ -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'],

View File

@@ -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];
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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());
}
}

View File

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

View File

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