Compare commits

...

9 Commits

44 changed files with 3601 additions and 15 deletions
@@ -112,6 +112,32 @@
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
### Décision d'architecture majeure (actée, à valider PO)
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
### Patterns
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
### Gotchas
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
### MR / Git
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62#56, #63#62, #57#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
+4
View File
@@ -69,4 +69,8 @@ services:
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+7 -4
View File
@@ -4,9 +4,12 @@ declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
* le RBAC fin par permission arrive en #1.2).
* Filtrée par SidebarFilter :
* - `module` : route ajoutée à disabledRoutes si module inactif ;
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
@@ -28,7 +31,7 @@ return [
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
],
],
];
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('admin.roles.addRole')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
:empty-message="$t('admin.roles.empty')"
@row-click="openEdit"
>
<template #cell-isSystem="{ item }">
<span
v-if="item.isSystem"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
>
{{ $t('admin.roles.system') }}
</span>
</template>
<template #cell-permissions="{ item }">
<span class="text-neutral-600">{{ item.permissions.length }}</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="!item.isSystem"
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="handleDelete(item.id)"
/>
</template>
</DataTable>
<RoleDrawer
v-model="drawerOpen"
:item="selectedItem"
:permissions="permissions"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
import { usePermissionService } from '~/modules/core/services/permissions'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns = computed<DataTableColumn[]>(() => [
{ key: 'label', label: t('admin.roles.label'), primary: true },
{ key: 'code', label: t('admin.roles.code') },
{ key: 'permissions', label: t('admin.roles.permissions') },
{ key: 'isSystem', label: '' },
])
const roleService = useRoleService()
const permissionService = usePermissionService()
const items = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Role | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await roleService.list()
} finally {
isLoading.value = false
}
}
async function loadPermissions() {
permissions.value = await permissionService.list()
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Role) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await roleService.remove(id)
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
loadPermissions()
})
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
</h2>
</template>
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.code"
:label="$t('admin.roles.code')"
input-class="w-full"
:disabled="isEditing"
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
@blur="touched.code = true"
/>
<MalioInputText
v-model="form.label"
:label="$t('admin.roles.label')"
input-class="w-full"
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
@blur="touched.label = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('admin.roles.description')"
input-class="w-full"
/>
<div class="mt-2">
<label class="text-sm font-semibold text-neutral-700">
{{ $t('admin.roles.permissions') }}
</label>
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
{{ $t('admin.roles.noPermissions') }}
</p>
<div
v-for="group in groupedPermissions"
:key="group.module"
class="mt-3 rounded-lg border border-neutral-200 p-3"
>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
{{ group.module }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="perm in group.permissions"
:key="perm.id"
class="flex items-start gap-2 text-sm text-neutral-700"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm['@id']"
class="mt-0.5 rounded border-neutral-300"
/>
<span>
{{ perm.label }}
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
</span>
</label>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Role, RoleWrite } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
const props = defineProps<{
modelValue: boolean
item: Role | null
permissions: Permission[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
code: '',
label: '',
description: '',
permissions: [] as string[],
})
const touched = reactive({
code: false,
label: false,
})
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
const groupedPermissions = computed(() => {
const byModule = new Map<string, Permission[]>()
for (const perm of props.permissions) {
const list = byModule.get(perm.module) ?? []
list.push(perm)
byModule.set(perm.module, list)
}
return [...byModule.entries()]
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.code = props.item.code
form.label = props.item.label
form.description = props.item.description ?? ''
form.permissions = props.item.permissions
.map((p) => p['@id'])
.filter((iri): iri is string => !!iri)
} else {
form.code = ''
form.label = ''
form.description = ''
form.permissions = []
}
touched.code = false
touched.label = false
}
})
const { create, update } = useRoleService()
async function handleSubmit() {
touched.code = true
touched.label = true
if (!form.label.trim()) {
return
}
if (!isEditing.value && !codeValid.value) {
return
}
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
const payload: Partial<RoleWrite> = {
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await update(props.item.id, payload)
} else {
const payload: RoleWrite = {
code: form.code.trim(),
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
+21
View File
@@ -195,6 +195,27 @@
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"admin": {
"roles": {
"title": "Rôles",
"addRole": "Ajouter un rôle",
"editRole": "Modifier un rôle",
"empty": "Aucun rôle trouvé.",
"system": "Système",
"code": "Code",
"codeHint": "Identifiant technique en snake_case (immuable).",
"codeImmutable": "Le code ne peut pas être modifié après création.",
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
"label": "Libellé",
"labelRequired": "Le libellé est requis.",
"description": "Description",
"permissions": "Permissions",
"noPermissions": "Aucune permission disponible.",
"created": "Rôle créé avec succès.",
"updated": "Rôle mis à jour avec succès.",
"deleted": "Rôle supprimé avec succès."
}
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
@@ -0,0 +1,27 @@
export function usePermissions() {
const auth = useAuthStore()
function isAdmin(): boolean {
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
}
function can(code: string): boolean {
if (!auth.user) {
return false
}
if (isAdmin()) {
return true
}
return auth.user.effectivePermissions?.includes(code) ?? false
}
function canAny(codes: string[]): boolean {
return codes.some((c) => can(c))
}
function canAll(codes: string[]): boolean {
return codes.every((c) => can(c))
}
return { can, canAny, canAll, isAdmin }
}
@@ -0,0 +1,22 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Permission = {
id: number
'@id'?: string
code: string
label: string
module: string
orphan?: boolean
}
export function usePermissionService() {
const api = useApi()
async function list(): Promise<Permission[]> {
const data = await api.get<HydraCollection<Permission>>('/permissions')
return extractHydraMembers(data)
}
return { list }
}
+50
View File
@@ -0,0 +1,50 @@
import type { Permission } from './permissions'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Role = {
id: number
'@id'?: string
code: string
label: string
description?: string | null
isSystem: boolean
permissions: Permission[]
}
export type RoleWrite = {
code?: string
label: string
description?: string | null
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
permissions: string[]
}
export function useRoleService() {
const api = useApi()
async function list(): Promise<Role[]> {
const data = await api.get<HydraCollection<Role>>('/roles')
return extractHydraMembers(data)
}
async function create(payload: RoleWrite): Promise<Role> {
return api.post<Role>('/roles', payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.created',
})
}
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/roles/${id}`, {}, {
toastSuccessKey: 'admin.roles.deleted',
})
}
return { list, create, update, remove }
}
+12 -1
View File
@@ -6,7 +6,7 @@
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-4 sm:gap-6">
<button
v-for="tab in tabs"
v-for="tab in visibleTabs"
:key="tab.key"
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key
@@ -27,6 +27,7 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
@@ -41,6 +42,11 @@
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const { can } = usePermissions()
const { t } = useI18n()
const canViewRoles = computed(() => can('core.roles.view'))
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' },
@@ -48,6 +54,7 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
@@ -58,5 +65,9 @@ const tabs = [
type TabKey = typeof tabs[number]['key']
const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
)
const activeTab = ref<TabKey>('clients')
</script>
+1
View File
@@ -7,6 +7,7 @@ export type UserData = {
firstName?: string | null
lastName?: string | null
roles: string[]
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// HR / absence management
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RBAC fin (LST-57) : permission, role and their many-to-many relations with user.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619145109 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add RBAC permission and role entities with user relations (additive)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE permission (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, module VARCHAR(100) NOT NULL, orphan BOOLEAN NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_E04992AA77153098 ON permission (code)');
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
$this->addSql('COMMENT ON COLUMN permission.code IS \'Permission code (module.resource[.sub].action)\'');
$this->addSql('COMMENT ON COLUMN permission.label IS \'Human-readable permission label\'');
$this->addSql('COMMENT ON COLUMN permission.module IS \'Owning module id (e.g. core)\'');
$this->addSql('COMMENT ON COLUMN permission.orphan IS \'True when the permission is no longer declared by any active module\'');
$this->addSql('CREATE TABLE "role" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(100) NOT NULL, label VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, is_system BOOLEAN NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_57698A6A77153098 ON "role" (code)');
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
$this->addSql('COMMENT ON COLUMN "role".code IS \'Immutable role code (snake_case)\'');
$this->addSql('COMMENT ON COLUMN "role".label IS \'Human-readable role label\'');
$this->addSql('COMMENT ON COLUMN "role".description IS \'Optional role description\'');
$this->addSql('COMMENT ON COLUMN "role".is_system IS \'True for built-in roles that cannot be deleted\'');
$this->addSql('CREATE TABLE role_permission (role_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (role_id, permission_id))');
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
$this->addSql('CREATE TABLE user_role (user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id))');
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
$this->addSql('CREATE TABLE user_permission (user_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (user_id, permission_id))');
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC');
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA');
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395');
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC');
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395');
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA');
$this->addSql('DROP TABLE role_permission');
$this->addSql('DROP TABLE user_role');
$this->addSql('DROP TABLE user_permission');
$this->addSql('DROP TABLE permission');
$this->addSql('DROP TABLE "role"');
}
}
+6
View File
@@ -25,6 +25,7 @@ use App\Enum\AbsenceType;
use App\Enum\ContractType;
use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use DateTimeZone;
@@ -36,6 +37,7 @@ class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RbacSeeder $rbacSeeder,
) {}
public function load(ObjectManager $manager): void
@@ -751,5 +753,9 @@ class AppFixtures extends Fixture
$manager->persist($pendingMarriage);
$manager->flush();
// Seed des rôles système RBAC (admin, user). Idempotent ; aucune matrice
// métier attachée (cf. Décision 4 : les modules métier arrivent en 2.x).
$this->rbacSeeder->ensureSystemRoles();
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\Rbac;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RbacSeeder
{
public function __construct(
private EntityManagerInterface $em,
private RoleRepositoryInterface $roles,
) {}
/**
* Crée les rôles système s'ils sont absents. Idempotent.
*/
public function ensureSystemRoles(): void
{
$this->ensureRole(SystemRoles::ADMIN_CODE, 'Administrateur', 'Accès complet (bypass RBAC).');
$this->ensureRole(SystemRoles::USER_CODE, 'Utilisateur', 'Rôle de base sans permission spécifique.');
$this->em->flush();
}
private function ensureRole(string $code, string $label, string $description): void
{
if (null !== $this->roles->findByCode($code)) {
return;
}
$this->roles->save(new Role($code, $label, $description, true));
}
}
+6 -4
View File
@@ -24,16 +24,18 @@ final class CoreModule implements ModuleInterface
}
/**
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
* Permissions RBAC fin du Module Core (1.2).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gérer les utilisateurs (créer, éditer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
];
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
)]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read', 'role:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true, options: ['comment' => 'Permission code (module.resource[.sub].action)'])]
#[Groups(['permission:read', 'role:read'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable permission label'])]
#[Groups(['permission:read', 'role:read'])]
private string $label;
#[ORM\Column(length: 100, options: ['comment' => 'Owning module id (e.g. core)'])]
#[Groups(['permission:read', 'role:read'])]
private string $module;
#[ORM\Column(options: ['comment' => 'True when the permission is no longer declared by any active module'])]
#[Groups(['permission:read'])]
private bool $orphan = false;
public function __construct(string $code, string $label, string $module)
{
$code = trim($code);
$label = trim($label);
$module = trim($module);
if ('' === $code || !str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libellé de permission ne peut pas être vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module de permission ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
public function markOrphan(): void
{
$this->orphan = true;
}
public function revive(string $label, string $module): void
{
$this->orphan = false;
$this->updateMetadata($label, $module);
}
public function updateMetadata(string $label, string $module): void
{
$this->label = $label;
$this->module = $module;
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')", paginationEnabled: false),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Delete(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true, options: ['comment' => 'Immutable role code (snake_case)'])]
#[Groups(['role:read', 'role:write'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable role label'])]
#[Groups(['role:read', 'role:write'])]
private string $label;
#[ORM\Column(type: 'text', nullable: true, options: ['comment' => 'Optional role description'])]
#[Groups(['role:read', 'role:write'])]
private ?string $description;
#[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])]
private bool $isSystem;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false)
{
if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) {
throw new InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code));
}
if ('' === trim($label)) {
throw new InvalidArgumentException('Le libellé de rôle ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->description = $description;
$this->isSystem = $isSystem;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->label = $label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
// PropertyInfo strips the `is` prefix and would expose this field as `system`.
// An explicit SerializedName guarantees the `isSystem` key expected by API clients.
#[Groups(['role:read'])]
#[SerializedName('isSystem')]
public function isSystem(): bool
{
return $this->isSystem;
}
/**
* @return Collection<int, Permission>
*/
public function getPermissions(): Collection
{
return $this->permissions;
}
public function addPermission(Permission $permission): void
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
}
public function removePermission(Permission $permission): void
{
$this->permissions->removeElement($permission);
}
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw new SystemRoleDeletionException($this->code);
}
}
}
+97 -1
View File
@@ -13,10 +13,13 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -40,6 +43,18 @@ use Symfony\Component\Serializer\Attribute\Groups;
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Get(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
],
denormalizationContext: ['groups' => ['user:write']],
)]
@@ -135,9 +150,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
/**
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $rbacRoles;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -373,4 +406,67 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
return $this;
}
/**
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): void
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
}
public function removeRbacRole(Role $role): void
{
$this->rbacRoles->removeElement($role);
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): void
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
}
public function removeDirectPermission(Permission $permission): void
{
$this->directPermissions->removeElement($permission);
}
/**
* Permissions effectives = union (rôles RBAC → permissions) (permissions directes), triée, dédupliquée.
*
* @return list<string>
*/
#[Groups(['me:read', 'user:rbac:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use DomainException;
final class SystemRoleDeletionException extends DomainException
{
public function __construct(string $code)
{
parent::__construct(sprintf('Le rôle système "%s" ne peut pas être supprimé.', $code));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/** @return list<Permission> */
public function findAll(): array;
/** @return list<string> */
public function findAllCodes(): array;
public function save(Permission $permission): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/** @return list<Role> */
public function findAll(): array;
public function save(Role $role): void;
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<Role, null|Role>
*/
final readonly class RoleProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role
{
assert($data instanceof Role);
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->em->remove($data);
$this->em->flush();
return null;
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,30 @@
<?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 Doctrine\ORM\EntityManagerInterface;
use function assert;
/**
* @implements ProcessorInterface<User, User>
*/
final readonly class UserRbacProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
assert($data instanceof User);
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Application\Rbac\RbacSeeder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:seed-rbac', description: 'Seed les rôles système RBAC (admin, user).')]
final class SeedRbacCommand extends Command
{
public function __construct(private readonly RbacSeeder $seeder)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seeder->ensureSystemRoles();
$io->success('Rôles système RBAC seedés (admin, user).');
return Command::SUCCESS;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function count;
#[AsCommand(name: 'app:sync-permissions', description: 'Synchronise le catalogue des permissions depuis les modules actifs.')]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissions,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
// Phase 1 : permissions désirées (code => {code,label,module}).
$desired = [];
foreach (ModuleRegistry::permissions($moduleClasses) as $perm) {
$desired[$perm['code']] = $perm;
}
// Phase 2 : upsert.
$existing = [];
foreach ($this->permissions->findAll() as $permission) {
$existing[$permission->getCode()] = $permission;
}
$added = $updated = $revived = 0;
foreach ($desired as $code => $perm) {
$entity = $existing[$code] ?? null;
if (null === $entity) {
$this->permissions->save(new Permission($perm['code'], $perm['label'], $perm['module']));
++$added;
continue;
}
if ($entity->isOrphan()) {
$entity->revive($perm['label'], $perm['module']);
++$revived;
} elseif ($entity->getLabel() !== $perm['label'] || $entity->getModule() !== $perm['module']) {
$entity->updateMetadata($perm['label'], $perm['module']);
++$updated;
}
}
// Phase 3 : orphelines (existantes absentes des désirées).
$orphaned = 0;
foreach ($existing as $code => $entity) {
if (!isset($desired[$code]) && !$entity->isOrphan()) {
$entity->markOrphan();
++$orphaned;
}
}
$this->em->flush();
$io->success(sprintf('Permissions synchronisées : %d ajoutées, %d mises à jour, %d réactivées, %d orphelines. Total désirées : %d.', $added, $updated, $revived, $orphaned, count($desired)));
return Command::SUCCESS;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
final class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Permission> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
/** @return list<string> */
public function findAllCodes(): array
{
/** @var list<array{code: string}> $rows */
$rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult();
return array_map(static fn (array $r): string => $r['code'], $rows);
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
final class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Role> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
}
}
@@ -0,0 +1,38 @@
<?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;
/**
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
return 1 === preg_match(self::PATTERN, $attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// ROLE_ADMIN = bypass total (cf. Décision 1).
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
return in_array($attribute, $user->getEffectivePermissions(), true);
}
}
@@ -26,4 +26,7 @@ interface UserInterface
public function getAvatarUrl(): ?string;
public function getIsEmployee(): bool;
/** @return list<string> */
public function getEffectivePermissions(): array;
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Shared\Domain\Module;
use InvalidArgumentException;
final class ModuleRegistry
{
/**
@@ -22,4 +24,29 @@ final class ModuleRegistry
return $ids;
}
/**
* @param list<class-string> $moduleClasses
*
* @return list<array{code: string, label: string, module: string}>
*/
public static function permissions(array $moduleClasses): array
{
$out = [];
foreach ($moduleClasses as $moduleClass) {
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$moduleId = $moduleClass::id();
foreach ($moduleClass::permissions() as $perm) {
$code = $perm['code'];
if (!str_starts_with($code, $moduleId.'.')) {
throw new InvalidArgumentException(sprintf('Permission "%s" du module "%s" doit être préfixée par "%s.".', $code, $moduleId, $moduleId));
}
$out[] = ['code' => $code, 'label' => $perm['label'], 'module' => $moduleId];
}
}
return $out;
}
}
+27 -4
View File
@@ -7,13 +7,14 @@ namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:string}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
* @param list<string> $activePermissions
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = [], array $activePermissions = []): array
{
$outSections = [];
$disabledRoutes = [];
@@ -24,6 +25,11 @@ final class SidebarFilter
continue;
}
// Gate de permission au niveau section (RBAC fin).
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
continue;
}
$items = [];
foreach ($section['items'] as $item) {
// Gate de rôle au niveau item.
@@ -31,6 +37,11 @@ final class SidebarFilter
continue;
}
// Gate de permission au niveau item (RBAC fin).
if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) {
continue;
}
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
$module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
@@ -68,4 +79,16 @@ final class SidebarFilter
return false;
}
/**
* @param list<string> $activePermissions
*/
private static function permissionSatisfied(?string $required, array $activePermissions): bool
{
if (null === $required || '' === $required) {
return true;
}
return in_array($required, $activePermissions, true);
}
}
@@ -6,6 +6,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
@@ -31,7 +32,15 @@ final readonly class SidebarProvider implements ProviderInterface
$user = $this->security->getUser();
$roles = null !== $user ? $user->getRoles() : [];
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles));
// RBAC fin : permissions effectives du contrat. ROLE_ADMIN bypasse tout (Décision 1) :
// on lui injecte le catalogue complet des permissions déclarées pour satisfaire les gates.
if (in_array('ROLE_ADMIN', $roles, true)) {
$permissions = array_column(ModuleRegistry::permissions($moduleClasses), 'code');
} else {
$permissions = $user instanceof UserInterface ? $user->getEffectivePermissions() : [];
}
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions);
$dto = new SidebarResource();
$dto->sections = $filtered['sections'];
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class RoleApiTest extends WebTestCase
{
public function testGetCollectionRequiresAuthentication(): void
{
$client = self::createClient();
$client->request('GET', '/api/roles');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanListRoles(): void
{
$client = self::createClient();
$this->loginAdmin($client);
$client->request('GET', '/api/roles');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('member', $data);
}
public function testAdminCanCreateRole(): void
{
$client = self::createClient();
$this->loginAdmin($client);
$code = 'bureau_'.uniqid();
$client->request('POST', '/api/roles', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode(['code' => $code, 'label' => 'Bureau']));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame($code, $data['code']);
self::assertSame('Bureau', $data['label']);
self::assertFalse($data['isSystem']);
}
public function testDeletingSystemRoleIsForbidden(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$systemRole = new Role('sys_'.uniqid(), 'System role', 'Rôle système', true);
$em->persist($systemRole);
$em->flush();
$id = $systemRole->getId();
$this->loginAdmin($client);
$client->request('DELETE', '/api/roles/'.$id);
self::assertResponseStatusCodeSame(403);
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @internal
*/
final class SeedRbacCommandTest extends KernelTestCase
{
public function testSeedsSystemRolesIdempotently(): void
{
$kernel = self::bootKernel();
$app = new Application($kernel);
$tester = new CommandTester($app->find('app:seed-rbac'));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$tester->execute([]); // idempotent
$tester->assertCommandIsSuccessful();
$repo = self::getContainer()->get(RoleRepositoryInterface::class);
$admin = $repo->findByCode(SystemRoles::ADMIN_CODE);
self::assertNotNull($admin);
self::assertTrue($admin->isSystem());
self::assertNotNull($repo->findByCode(SystemRoles::USER_CODE));
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @internal
*/
final class SyncPermissionsCommandTest extends KernelTestCase
{
public function testSyncCreatesCorePermissions(): void
{
$kernel = self::bootKernel();
$app = new Application($kernel);
$tester = new CommandTester($app->find('app:sync-permissions'));
$tester->execute([]);
$tester->assertCommandIsSuccessful();
$repo = self::getContainer()->get(PermissionRepositoryInterface::class);
self::assertNotNull($repo->findByCode('core.users.manage'));
self::assertContains('core.roles.manage', $repo->findAllCodes());
}
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
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\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class UserRbacApiTest extends WebTestCase
{
public function testAdminCanReadUserRbac(): void
{
$client = self::createClient();
$this->loginAdmin($client);
$aliceId = $this->userId('alice');
$client->request('GET', '/api/users/'.$aliceId.'/rbac');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('rbacRoles', $data);
self::assertArrayHasKey('directPermissions', $data);
self::assertArrayHasKey('effectivePermissions', $data);
}
public function testRbacRequiresAuthentication(): void
{
$client = self::createClient();
$aliceId = $this->userId('alice');
$client->request('GET', '/api/users/'.$aliceId.'/rbac');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanAssignRoleViaPatch(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$roleCode = 'reviewer_'.uniqid();
$role = new Role($roleCode, 'Reviewer');
$em->persist($role);
$target = $this->createUser($em, 'rbac-assign-'.uniqid());
$em->flush();
$roleId = $role->getId();
$targetId = $target->getId();
$this->loginAdmin($client);
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]]));
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertCount(1, $data['rbacRoles']);
$em->clear();
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertCount(1, $reloaded->getRbacRoles());
self::assertSame($roleCode, $reloaded->getRbacRoles()->first()->getCode());
}
public function testPartialPatchDoesNotWipeOtherCollection(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
self::assertInstanceOf(Permission::class, $permission);
$role = new Role('auditor_'.uniqid(), 'Auditor');
$em->persist($role);
// Dedicated user pre-loaded with a direct permission.
$target = $this->createUser($em, 'rbac-partial-'.uniqid());
$target->addDirectPermission($permission);
$em->flush();
$roleId = $role->getId();
$targetId = $target->getId();
$this->loginAdmin($client);
// PATCH only rbacRoles — directPermissions is absent from the payload.
$client->request('PATCH', '/api/users/'.$targetId.'/rbac', server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['rbacRoles' => ['/api/roles/'.$roleId]]));
self::assertResponseIsSuccessful();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertCount(1, $reloaded->getRbacRoles(), 'Role should have been assigned');
self::assertCount(1, $reloaded->getDirectPermissions(), 'Direct permission must not be wiped by a partial PATCH');
}
private function createUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function userId(string $username): int
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class PermissionTest extends TestCase
{
public function testValidConstruction(): void
{
$p = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
self::assertSame('core.users.view', $p->getCode());
self::assertSame('Voir les utilisateurs', $p->getLabel());
self::assertSame('core', $p->getModule());
self::assertFalse($p->isOrphan());
}
public function testCodeMustContainADot(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('coreusersview', 'x', 'core');
}
public function testCodeCannotBeEmpty(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('', 'x', 'core');
}
public function testLabelCannotBeEmpty(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', '', 'core');
}
public function testModuleCannotBeEmpty(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', 'x', '');
}
public function testMarkOrphanAndRevive(): void
{
$p = new Permission('core.users.view', 'Voir', 'core');
$p->markOrphan();
self::assertTrue($p->isOrphan());
$p->revive('Voir maj', 'core');
self::assertFalse($p->isOrphan());
self::assertSame('Voir maj', $p->getLabel());
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class RoleTest extends TestCase
{
public function testValidConstruction(): void
{
$r = new Role('bureau', 'Bureau');
self::assertSame('bureau', $r->getCode());
self::assertSame('Bureau', $r->getLabel());
self::assertFalse($r->isSystem());
self::assertCount(0, $r->getPermissions());
}
public function testCodeMustBeSnakeCase(): void
{
$this->expectException(InvalidArgumentException::class);
new Role('Bureau Commercial', 'x');
}
public function testAddRemovePermission(): void
{
$r = new Role('bureau', 'Bureau');
$p = new Permission('core.users.view', 'Voir', 'core');
$r->addPermission($p);
self::assertCount(1, $r->getPermissions());
$r->addPermission($p); // idempotent
self::assertCount(1, $r->getPermissions());
$r->removePermission($p);
self::assertCount(0, $r->getPermissions());
}
public function testSystemRoleCannotBeDeleted(): void
{
$r = new Role('admin', 'Administrateur', null, true);
$this->expectException(SystemRoleDeletionException::class);
$r->ensureDeletable();
}
public function testNonSystemRoleIsDeletable(): void
{
$r = new Role('bureau', 'Bureau');
$r->ensureDeletable();
self::assertFalse($r->isSystem());
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\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\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @internal
*/
final class PermissionVoterTest extends TestCase
{
public function testAbstainsOnNonRbacAttributes(): void
{
$voter = new PermissionVoter();
$user = new User();
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['ROLE_ADMIN']));
self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['IS_AUTHENTICATED_FULLY']));
}
public function testGrantsWhenUserHasPermissionViaRole(): void
{
$voter = new PermissionVoter();
$role = new Role('bureau', 'Bureau');
$role->addPermission(new Permission('core.users.view', 'Voir', 'core'));
$user = new User();
$user->addRbacRole($role);
self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.view']));
self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token($user), null, ['core.users.manage']));
}
public function testAdminBypassesViaRole(): void
{
$voter = new PermissionVoter();
$user = new User();
$user->setRoles(['ROLE_ADMIN']);
self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.manage']));
}
private function token(User $user): UsernamePasswordToken
{
return new UsernamePasswordToken($user, 'main', $user->getRoles());
}
}
@@ -115,6 +115,11 @@ final class TimestampableBlamableSubscriberTest extends TestCase
{
return false;
}
public function getEffectivePermissions(): array
{
return [];
}
};
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Shared\Module;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleRegistry;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class ModuleRegistryPermissionsTest extends TestCase
{
public function testAggregatesPermissionsWithModuleId(): void
{
$perms = ModuleRegistry::permissions([CoreModule::class]);
self::assertNotEmpty($perms);
foreach ($perms as $perm) {
self::assertArrayHasKey('code', $perm);
self::assertArrayHasKey('label', $perm);
self::assertArrayHasKey('module', $perm);
self::assertSame('core', $perm['module']);
self::assertStringStartsWith('core.', $perm['code']);
}
}
}
@@ -103,4 +103,28 @@ final class SidebarFilterTest extends TestCase
self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]);
}
public function testItemHiddenWhenPermissionMissing(): void
{
$sections = [[
'label' => 's', 'icon' => 'i',
'items' => [
['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view'],
['label' => 'b', 'to' => '/b', 'icon' => 'i'],
],
]];
$out = SidebarFilter::filter($sections, [], [], []);
self::assertCount(1, $out['sections'][0]['items']);
self::assertSame('/b', $out['sections'][0]['items'][0]['to']);
}
public function testItemVisibleWhenPermissionGranted(): void
{
$sections = [[
'label' => 's', 'icon' => 'i',
'items' => [['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view']],
]];
$out = SidebarFilter::filter($sections, [], [], ['core.users.view']);
self::assertCount(1, $out['sections'][0]['items']);
}
}