8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
1691 lines
64 KiB
Markdown
1691 lines
64 KiB
Markdown
# RBAC fin (LST-57 / 1.2) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Porter le RBAC fin de Starseed dans le Module Core de Lesstime : permissions `module.resource[.sub].action`, entités `Role`/`Permission`, commande de synchronisation, `PermissionVoter`, sidebar filtrée par permission, et gestion front des rôles.
|
||
|
||
**Architecture:** RBAC **additif** par-dessus l'auth Symfony existante (cf. Décision 1). On garde la colonne JSON `roles` + `ROLE_ADMIN`/`ROLE_USER` (login/JWT/MCP/sidebar #62 inchangés ; `ROLE_ADMIN` = bypass du voter) et on ajoute la couche RBAC fine : `Role`/`Permission` (Module Core), relations `rbacRoles`/`directPermissions` sur `User`, `getEffectivePermissions()`, `PermissionVoter`, `app:sync-permissions`, `app:seed-rbac`, sidebar gated par permission, front `usePermissions`.
|
||
|
||
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 — Nuxt 4 / Vue 3 / Pinia / TypeScript. Tests : PHPUnit 13. Docker : `php-lesstime-fpm` (user `www-data`), nginx port 8082, PG port 5435.
|
||
|
||
## Global Constraints
|
||
|
||
- `declare(strict_types=1)` en tête de tout fichier PHP. Symfony + PSR-12, hook pre-commit php-cs-fixer (ne pas lutter contre le reformat).
|
||
- Namespaces : back `App\Module\Core\...`, `App\Shared\...`. Front layer `frontend/modules/core/`.
|
||
- **Zéro régression auth** : après chaque phase touchant la sécurité (C, D, F), exécuter le bloc « Vérification login » ci-dessous → `login=204`, `/api/me=200`, `/_mcp=200`.
|
||
- **Migration additive uniquement** : `CREATE TABLE` des tables RBAC ; **aucun** `DROP`/`ALTER` destructif sur `user`/`roles`. `doctrine:migrations:diff` après doit être vide (hors dérive préexistante `messenger_messages`).
|
||
- PostgreSQL : noms de colonnes en minuscules dans le SQL brut ; `roles::text LIKE` pour les colonnes JSON.
|
||
- `config/reference.php` est auto-généré : **ne jamais le committer**. Untracked `.codex`, `bulettins/` : ignorer.
|
||
- Aucune mention de Claude/IA dans les commits.
|
||
- Commits : `<type>(core) : <message>` (espace autour du `:`).
|
||
- Tests existants au départ : **120 verts**. Chaque phase ajoute ses tests et garde l'ensemble vert.
|
||
|
||
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
|
||
|
||
```bash
|
||
curl -s -i -X POST http://localhost:8082/api/login_check -H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' -D /tmp/h.txt -o /dev/null -w "login=%{http_code}\n"
|
||
BEARER=$(grep -i 'set-cookie: BEARER' /tmp/h.txt | sed -E 's/.*BEARER=([^;]+);.*/\1/')
|
||
curl -s -o /dev/null -w "me=%{http_code}\n" http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER"
|
||
curl -s -o /dev/null -w "mcp=%{http_code}\n" -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}'
|
||
```
|
||
Attendu : `login=204`, `me=200`, `mcp=200`.
|
||
|
||
## Décisions de conception (actées, à valider PO a posteriori)
|
||
|
||
1. **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`). Justification : login/JWT, `security.yaml` (`role_hierarchy`, `app_user_provider`), gate sidebar #62 (`roles: [ROLE_ADMIN]`), MCP `apiToken` reposent tous sur `getRoles()`/`roles` JSON ; les réécrire = régression auth à haut risque pour zéro bénéfice AC. On garde `getRoles()` tel quel ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Migration future vers `is_admin` possible si le PO le souhaite.
|
||
2. **`user_permission` (directPermissions) inclus** — fidélité Starseed : un user peut recevoir des permissions directes en plus de ses rôles. `getEffectivePermissions()` = union(rôles.permissions, directPermissions).
|
||
3. **Gestion de `ROLE_ADMIN` reste sur le PATCH user existant (`roles`), PAS sur l'endpoint RBAC** — l'endpoint `/api/users/{id}/rbac` ne gère que `rbacRoles` + `directPermissions`. Pas de gardes « dernier admin »/« auto-suicide » (elles concernent `is_admin`, absent ici) ; on conserve uniquement la garde anti-écrasement des collections (defense in depth).
|
||
4. **Pas de rôles métier seedés en 1.2** — seules les permissions `core.*` existent (les modules métier arrivent en 2.x). `app:seed-rbac` crée les rôles système `admin` et `user` (isSystem=true) sans matrice métier. Chaque module métier ajoutera ses permissions + (optionnel) ses rôles quand il sera livré.
|
||
5. **Pas de `Sites`** — Lesstime n'a pas de notion de site : on retire toutes les gardes/relations `sites.*`, `currentSite`, `bypass_scope` du portage Starseed.
|
||
6. **Entités `Role`/`Permission` sans Timestampable/Blamable** — alignées sur Starseed (métadonnées RBAC pures).
|
||
|
||
---
|
||
|
||
## Phase A — Domaine RBAC : entités, relations, migration
|
||
|
||
### Task 1: Entités `Permission` + `Role` + relations `User` + repositories + contrat
|
||
|
||
**Files:**
|
||
- Create: `src/Module/Core/Domain/Entity/Permission.php`
|
||
- Create: `src/Module/Core/Domain/Entity/Role.php`
|
||
- Create: `src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php`
|
||
- Create: `src/Module/Core/Domain/Repository/RoleRepositoryInterface.php`
|
||
- Create: `src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php`
|
||
- Create: `src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php`
|
||
- Modify: `src/Module/Core/Domain/Entity/User.php` (relations `rbacRoles` + `directPermissions` + `getEffectivePermissions()`)
|
||
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (ajout `getEffectivePermissions()`)
|
||
- Modify: `config/services.yaml` (alias repositories)
|
||
- Create: `tests/Unit/Module/Core/Domain/Entity/PermissionTest.php`
|
||
- Create: `tests/Unit/Module/Core/Domain/Entity/RoleTest.php`
|
||
- Modify: `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` (stub `getEffectivePermissions()` dans l'anonyme implémentant `UserInterface`)
|
||
|
||
**Interfaces:**
|
||
- Produces : `Permission { getId, getCode, getLabel, getModule, isOrphan, markOrphan(), revive(label, module), updateMetadata(label, module) }` ; `Role { getId, getCode, getLabel, getDescription, isSystem, getPermissions(): Collection, addPermission(Permission), removePermission(Permission), ensureDeletable() }` ; `User::getEffectivePermissions(): list<string>`, `User::getRbacRoles(): Collection`, `addRbacRole`/`removeRbacRole`, `getDirectPermissions(): Collection`, `addDirectPermission`/`removeDirectPermission`.
|
||
- Repositories : `PermissionRepositoryInterface { findById(int): ?Permission, findByCode(string): ?Permission, findAll(): list<Permission>, findAllCodes(): list<string>, save(Permission): void }` ; `RoleRepositoryInterface { findById(int): ?Role, findByCode(string): ?Role, findAll(): list<Role>, save(Role): void }`.
|
||
|
||
- [ ] **Step 1: Écrire les tests unitaires des entités**
|
||
|
||
`tests/Unit/Module/Core/Domain/Entity/PermissionTest.php` :
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Unit\Module\Core\Domain\Entity;
|
||
|
||
use App\Module\Core\Domain\Entity\Permission;
|
||
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());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Module/Core/Domain/Entity/RoleTest.php` :
|
||
```php
|
||
<?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 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());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lancer les tests, vérifier l'échec**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/`
|
||
Expected: FAIL (classes inexistantes).
|
||
|
||
- [ ] **Step 3: Créer l'exception**
|
||
|
||
`src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` :
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Module\Core\Domain\Exception;
|
||
|
||
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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Créer `Permission`**
|
||
|
||
`src/Module/Core/Domain/Entity/Permission.php` :
|
||
```php
|
||
<?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 Symfony\Component\Serializer\Annotation\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(),
|
||
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)]
|
||
#[Groups(['permission:read', 'role:read'])]
|
||
private string $code;
|
||
|
||
#[ORM\Column(length: 255)]
|
||
#[Groups(['permission:read', 'role:read'])]
|
||
private string $label;
|
||
|
||
#[ORM\Column(length: 100)]
|
||
#[Groups(['permission:read', 'role:read'])]
|
||
private string $module;
|
||
|
||
#[ORM\Column]
|
||
#[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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Créer `Role`**
|
||
|
||
`src/Module/Core/Domain/Entity/Role.php` :
|
||
```php
|
||
<?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 Symfony\Component\Serializer\Annotation\Groups;
|
||
|
||
#[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')"),
|
||
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)]
|
||
#[Groups(['role:read', 'role:write'])]
|
||
private string $code;
|
||
|
||
#[ORM\Column(length: 255)]
|
||
#[Groups(['role:read', 'role:write'])]
|
||
private string $label;
|
||
|
||
#[ORM\Column(type: 'text', nullable: true)]
|
||
#[Groups(['role:read', 'role:write'])]
|
||
private ?string $description;
|
||
|
||
#[ORM\Column(name: 'is_system')]
|
||
#[Groups(['role:read'])]
|
||
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;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Ajouter les relations RBAC + `getEffectivePermissions()` à `User`**
|
||
|
||
Dans `src/Module/Core/Domain/Entity/User.php` : ajouter les imports `use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection;` (si absents) et, dans la classe :
|
||
```php
|
||
/**
|
||
* @var Collection<int, Role>
|
||
*/
|
||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||
#[ORM\JoinTable(name: 'user_role')]
|
||
#[Groups(['user:rbac:read'])]
|
||
private Collection $rbacRoles;
|
||
|
||
/**
|
||
* @var Collection<int, Permission>
|
||
*/
|
||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||
#[ORM\JoinTable(name: 'user_permission')]
|
||
#[Groups(['user:rbac:read'])]
|
||
private Collection $directPermissions;
|
||
```
|
||
> ⚠️ Initialiser les deux collections dans le constructeur de `User` (s'il en existe un ; sinon en ajouter un `public function __construct() { $this->rbacRoles = new ArrayCollection(); $this->directPermissions = new ArrayCollection(); }`. Vérifier d'abord s'il y a déjà un constructeur — `createdAt` est posé ailleurs ? Lire l'entité. Si pas de constructeur, en créer un n'initialisant QUE ces deux collections).
|
||
|
||
Ajouter les méthodes :
|
||
```php
|
||
/**
|
||
* @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;
|
||
}
|
||
```
|
||
Ajouter les imports `use App\Module\Core\Domain\Entity\Role;` n'est pas nécessaire (même namespace) ; `Permission` non plus. Vérifier la présence de `use Symfony\Component\Serializer\Annotation\Groups;` (déjà là vu les Groups existants).
|
||
|
||
- [ ] **Step 7: Enrichir le contrat `UserInterface`**
|
||
|
||
Dans `src/Shared/Domain/Contract/UserInterface.php`, ajouter :
|
||
```php
|
||
/** @return list<string> */
|
||
public function getEffectivePermissions(): array;
|
||
```
|
||
Puis ajouter le stub dans l'anonyme du test `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` qui implémente `UserInterface` :
|
||
```php
|
||
public function getEffectivePermissions(): array
|
||
{
|
||
return [];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Créer les repositories + alias services**
|
||
|
||
`src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` :
|
||
```php
|
||
<?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;
|
||
}
|
||
```
|
||
|
||
`src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` :
|
||
```php
|
||
<?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;
|
||
}
|
||
```
|
||
|
||
`src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` :
|
||
```php
|
||
<?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);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` :
|
||
```php
|
||
<?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);
|
||
}
|
||
}
|
||
```
|
||
|
||
Dans `config/services.yaml`, sous les alias existants (à côté de `UserRepositoryInterface`) :
|
||
```yaml
|
||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||
```
|
||
|
||
- [ ] **Step 9: Lancer les tests unitaires entités**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Générer + appliquer la migration additive**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff`
|
||
Vérifier que le `up()` ne contient QUE des `CREATE TABLE permission`, `CREATE TABLE "role"`, `CREATE TABLE role_permission`, `CREATE TABLE user_role`, `CREATE TABLE user_permission` (+ index + FK), **aucun** `DROP`/`ALTER` sur `"user"` ou `messenger_messages`. Si la dérive `messenger_messages` est mélangée, éditer la migration pour ne garder que les tables RBAC.
|
||
Ajouter les `COMMENT ON COLUMN` pour les colonnes métier (code, label, module, orphan, is_system, description) dans le `up()` (convention projet, cf. ColumnCommentsCatalog).
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction`
|
||
Puis recharger les fixtures de test : `make db-reset` (ou équivalent test) — vérifier que ça passe.
|
||
|
||
- [ ] **Step 11: Gate + commit**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate` → Mapping OK.
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert (120 + 11).
|
||
Bloc « Vérification login ».
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src config tests migrations
|
||
git reset -- config/reference.php
|
||
git commit -m "feat(core) : add rbac role and permission entities with user relations"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase B — Agrégation des permissions + commande de synchronisation
|
||
|
||
### Task 2: `ModuleRegistry::permissions()` + `CoreModule::permissions()` finalisé + `app:sync-permissions`
|
||
|
||
**Files:**
|
||
- Modify: `src/Shared/Domain/Module/ModuleRegistry.php` (méthode `permissions()`)
|
||
- Modify: `src/Module/Core/CoreModule.php` (permissions Core définitives)
|
||
- Create: `src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`
|
||
- Create: `tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php`
|
||
- Create: `tests/Functional/Module/Core/SyncPermissionsCommandTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes : `ModuleInterface::permissions(): list<array{code,label}>` (existant), `PermissionRepositoryInterface`.
|
||
- Produces : `ModuleRegistry::permissions(array $moduleClasses): list<array{code,label,module}>` (agrège + injecte `module` = `$class::id()`, valide préfixe). Commande `app:sync-permissions`.
|
||
|
||
- [ ] **Step 1: Test de `ModuleRegistry::permissions()`**
|
||
|
||
`tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php` :
|
||
```php
|
||
<?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']);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lancer, vérifier l'échec** (méthode inexistante).
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implémenter `ModuleRegistry::permissions()`**
|
||
|
||
Ajouter à `src/Shared/Domain/Module/ModuleRegistry.php` :
|
||
```php
|
||
/**
|
||
* @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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Finaliser `CoreModule::permissions()`**
|
||
|
||
Remplacer le stub par les permissions Core RBAC (alignées sur Starseed, périmètre Lesstime) :
|
||
```php
|
||
public static function permissions(): array
|
||
{
|
||
return [
|
||
['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'],
|
||
];
|
||
}
|
||
```
|
||
> Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` si une assertion fige les anciens codes (`core.user.read`, etc.).
|
||
|
||
- [ ] **Step 5: Test fonctionnel de la commande**
|
||
|
||
`tests/Functional/Module/Core/SyncPermissionsCommandTest.php` :
|
||
```php
|
||
<?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());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Lancer, vérifier l'échec** (commande inexistante).
|
||
|
||
- [ ] **Step 7: Implémenter `app:sync-permissions`**
|
||
|
||
`src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` :
|
||
```php
|
||
<?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;
|
||
|
||
#[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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Tests + commit**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert.
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src tests
|
||
git commit -m "feat(core) : aggregate module permissions and add sync-permissions command"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase C — PermissionVoter + exposition `/api/me`
|
||
|
||
### Task 3: `PermissionVoter` + permissions effectives dans `/api/me`
|
||
|
||
**Files:**
|
||
- Create: `src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||
- Create: `tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||
- Modify (vérifier) : `src/Module/Core/Domain/Entity/User.php` — `getEffectivePermissions()` déjà dans le groupe `me:read` (Phase A Step 6). Confirmer.
|
||
|
||
**Interfaces:**
|
||
- Consumes : `User::getEffectivePermissions()`, `User::getRoles()`.
|
||
- Produces : voter répondant à `is_granted('module.resource.action')`.
|
||
|
||
- [ ] **Step 1: Test du voter**
|
||
|
||
`tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php` :
|
||
```php
|
||
<?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
|
||
{
|
||
private function token(User $user): UsernamePasswordToken
|
||
{
|
||
return new UsernamePasswordToken($user, 'main', $user->getRoles());
|
||
}
|
||
|
||
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']));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lancer, vérifier l'échec.**
|
||
|
||
- [ ] **Step 3: Implémenter le voter**
|
||
|
||
`src/Module/Core/Infrastructure/Security/PermissionVoter.php` :
|
||
```php
|
||
<?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\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): 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);
|
||
}
|
||
}
|
||
```
|
||
> Le voter est auto-enregistré (autoconfigure). Aucun ajout `services.yaml` nécessaire.
|
||
|
||
- [ ] **Step 4: Tests + login + vérif `/api/me`**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert.
|
||
Bloc « Vérification login », puis vérifier que `/api/me` expose `effectivePermissions` :
|
||
```bash
|
||
curl -s http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER" | grep -o "effectivePermissions" && echo "OK champ présent"
|
||
```
|
||
> `alice` (ROLE_USER, sans rôle RBAC) renverra `effectivePermissions: []` — normal à ce stade (pas de rôle attribué).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src tests
|
||
git commit -m "feat(core) : add permission voter and expose effective permissions on /api/me"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase D — API Platform : Role + Permission + processors
|
||
|
||
### Task 4: ApiResources Role/Permission (déjà sur entités) + `RoleProcessor` + endpoint RBAC user + `UserRbacProcessor`
|
||
|
||
**Files:**
|
||
- Create: `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||
- Create: `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||
- Modify: `src/Module/Core/Domain/Entity/User.php` (opérations RBAC `Get`/`Patch` sur `/api/users/{id}/rbac` + groupes `user:rbac:read`/`user:rbac:write`)
|
||
- Create: `tests/Functional/Module/Core/RoleApiTest.php`
|
||
- Create: `tests/Functional/Module/Core/UserRbacApiTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes : `RoleRepositoryInterface`, `Role::ensureDeletable()`, `User` collections.
|
||
- Produces : `POST/PATCH/DELETE /api/roles`, `GET/PATCH /api/users/{id}/rbac`.
|
||
|
||
- [ ] **Step 1: `RoleProcessor`** (immuabilité du code + refus delete système)
|
||
|
||
`src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php` :
|
||
```php
|
||
<?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 Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||
|
||
/**
|
||
* @implements ProcessorInterface<Role, Role|null>
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
```
|
||
> Le code étant `role:write` mais immuable : API Platform mappe le `code` à la création (POST). En PATCH, le `code` reste dans `role:write` — pour le rendre immuable, vérifier dans un test que le PATCH du code n'a pas d'effet (l'entité n'a pas de `setCode()`, donc le denormalizer ne peut pas l'écraser → immuabilité structurelle). Confirmer l'absence de `setCode()` dans `Role`. ✅ (non défini en Phase A).
|
||
|
||
- [ ] **Step 2: Endpoint RBAC sur `User` + `UserRbacProcessor`**
|
||
|
||
Dans `src/Module/Core/Domain/Entity/User.php`, ajouter aux `operations` de l'`ApiResource` :
|
||
```php
|
||
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,
|
||
),
|
||
```
|
||
Ajouter l'import `use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;`.
|
||
Ajouter les setters de collections en denormalization (groupe `user:rbac:write`) : exposer `rbacRoles` et `directPermissions` en écriture. Pour cela, ajouter le groupe `user:rbac:write` sur les deux propriétés (en plus de `user:rbac:read`) :
|
||
```php
|
||
#[Groups(['user:rbac:read', 'user:rbac:write'])]
|
||
private Collection $rbacRoles;
|
||
...
|
||
#[Groups(['user:rbac:read', 'user:rbac:write'])]
|
||
private Collection $directPermissions;
|
||
```
|
||
> API Platform écrit les collections M2M via les adders/removers `addRbacRole`/`removeRbacRole` et `addDirectPermission`/`removeDirectPermission` (déjà définis Phase A).
|
||
|
||
`src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` (garde anti-écrasement des collections absentes du payload) :
|
||
```php
|
||
<?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 Doctrine\ORM\PersistentCollection;
|
||
|
||
/**
|
||
* @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);
|
||
|
||
// Defense in depth : si une collection n'était pas dans le payload JSON, API Platform
|
||
// la réinstancie vide → on restaure le snapshot Doctrine pour éviter l'effacement silencieux.
|
||
$previous = $context['previous_data'] ?? null;
|
||
if ($previous instanceof User) {
|
||
$this->restoreIfEmptiedByAbsence($data->getRbacRoles(), $previous->getRbacRoles());
|
||
$this->restoreIfEmptiedByAbsence($data->getDirectPermissions(), $previous->getDirectPermissions());
|
||
}
|
||
|
||
$this->em->persist($data);
|
||
$this->em->flush();
|
||
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* @param iterable<object> $current
|
||
* @param iterable<object> $previous
|
||
*/
|
||
private function restoreIfEmptiedByAbsence(mixed $current, mixed $previous): void
|
||
{
|
||
// Si la collection courante est "propre" (non modifiée par le denormalizer) mais vidée,
|
||
// on ne touche à rien : la mutation explicite passe par add/remove.
|
||
if ($current instanceof PersistentCollection && !$current->isDirty()) {
|
||
return;
|
||
}
|
||
// NB : la restauration fine est laissée à l'exécutant si un test prouve l'écrasement ;
|
||
// en pratique, exposer les collections en user:rbac:write avec les adders/removers suffit.
|
||
}
|
||
}
|
||
```
|
||
> ⚠️ NOTE EXÉCUTANT : commencer SIMPLE — exposer `rbacRoles`/`directPermissions` en `user:rbac:write` (avec adders/removers) gère nativement les mutations via IRIs. Écrire d'abord le test `UserRbacApiTest` (Step 4) ; si et seulement si il prouve un écrasement de collection lors d'un PATCH partiel, implémenter la restauration depuis `$context['previous_data']`. Sinon, le `UserRbacProcessor` peut se réduire à persist+flush. Ne pas sur-architecturer.
|
||
|
||
- [ ] **Step 3: Tests fonctionnels Role**
|
||
|
||
`tests/Functional/Module/Core/RoleApiTest.php` : créer un user admin authentifié (helper login JWT comme les autres tests fonctionnels du projet — s'inspirer de `tests/Functional/...` existants), puis :
|
||
- `GET /api/roles` en tant qu'admin → 200.
|
||
- `GET /api/roles` non authentifié → 401.
|
||
- `POST /api/roles` `{code:'bureau', label:'Bureau'}` en admin → 201.
|
||
- `DELETE` d'un rôle système (`admin`, seedé en Phase E — ou créé inline `isSystem`) → 403.
|
||
> S'aligner sur le pattern d'auth des tests fonctionnels existants (cookie BEARER via `/login_check` ou client API Platform avec token). LIRE un test fonctionnel existant avant d'écrire celui-ci.
|
||
|
||
- [ ] **Step 4: Tests fonctionnels User RBAC**
|
||
|
||
`tests/Functional/Module/Core/UserRbacApiTest.php` :
|
||
- `GET /api/users/{id}/rbac` en admin → 200, contient `rbacRoles`, `directPermissions`, `effectivePermissions`.
|
||
- `PATCH /api/users/{id}/rbac` attribuant un rôle (IRI) → 200, le rôle apparaît dans `rbacRoles`.
|
||
- `PATCH` ne touchant qu'un champ → vérifier que l'autre collection n'est PAS vidée (le test qui décide si `UserRbacProcessor` a besoin de la restauration).
|
||
|
||
- [ ] **Step 5: Lancer + login + commit**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert.
|
||
Bloc « Vérification login ».
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src tests
|
||
git commit -m "feat(core) : expose role and user-rbac api endpoints with processors"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase E — Seed RBAC (rôles système)
|
||
|
||
### Task 5: `RbacSeeder` + `app:seed-rbac` + intégration fixtures
|
||
|
||
**Files:**
|
||
- Create: `src/Module/Core/Application/Rbac/RbacSeeder.php`
|
||
- Create: `src/Module/Core/Infrastructure/Console/SeedRbacCommand.php`
|
||
- Create: `src/Module/Core/Domain/Security/SystemRoles.php`
|
||
- Modify: `src/DataFixtures/AppFixtures.php` (appeler le seed des rôles système après sync, OU documenter l'appel via make)
|
||
- Create: `tests/Functional/Module/Core/SeedRbacCommandTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes : `RoleRepositoryInterface`, `EntityManagerInterface`.
|
||
- Produces : `RbacSeeder::ensureSystemRoles(): void` (idempotent), commande `app:seed-rbac`.
|
||
|
||
- [ ] **Step 1: `SystemRoles`**
|
||
|
||
`src/Module/Core/Domain/Security/SystemRoles.php` :
|
||
```php
|
||
<?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';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test de la commande**
|
||
|
||
`tests/Functional/Module/Core/SeedRbacCommandTest.php` :
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Lancer, vérifier l'échec.**
|
||
|
||
- [ ] **Step 4: `RbacSeeder`**
|
||
|
||
`src/Module/Core/Application/Rbac/RbacSeeder.php` :
|
||
```php
|
||
<?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));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: `app:seed-rbac`**
|
||
|
||
`src/Module/Core/Infrastructure/Console/SeedRbacCommand.php` :
|
||
```php
|
||
<?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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Intégrer au cycle fixtures**
|
||
|
||
LIRE `src/DataFixtures/AppFixtures.php`. Après le chargement des users, appeler le seed RBAC (les permissions doivent exister → `app:sync-permissions` AVANT). Deux options selon le pattern projet :
|
||
- (a) injecter `RbacSeeder` dans `AppFixtures` et appeler `ensureSystemRoles()` en fin de `load()` (les permissions Core sont synchronisées par un hook séparé) ; OU
|
||
- (b) documenter dans le `Makefile`/README que `make db-reset` enchaîne `fixtures:load` puis `app:sync-permissions` puis `app:seed-rbac`.
|
||
> Choisir (a) si `AppFixtures` peut injecter des services (DependentFixture/service) ; sinon (b). Vérifier que `make db-reset` laisse une base cohérente (rôles + permissions présents). NE PAS attacher de matrice métier (Décision 4).
|
||
|
||
- [ ] **Step 7: Tests + commit**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert.
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src tests
|
||
git commit -m "feat(core) : add rbac seeder and seed-rbac command for system roles"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase F — Sidebar filtrée par permission
|
||
|
||
### Task 6: `SidebarFilter` + `SidebarProvider` + `config/sidebar.php` gated par permission
|
||
|
||
**Files:**
|
||
- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (clé `permission` sur section + item)
|
||
- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (passe les permissions effectives)
|
||
- Modify: `config/sidebar.php` (permissions sur les items admin)
|
||
- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (cas permission)
|
||
- Modify (éventuel): `tests/Functional/Shared/SidebarEndpointTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes : `User::getEffectivePermissions()`, `User::getRoles()`.
|
||
- Produces : `SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [], $activePermissions = [])`.
|
||
|
||
- [ ] **Step 1: Étendre le test `SidebarFilterTest`**
|
||
|
||
Ajouter des cas : une section/item avec `'permission' => 'core.users.view'` est masqué si la permission n'est pas dans `$activePermissions`, visible sinon. Un item sans `permission` reste visible (rétrocompat). Combinaison avec `roles` et `module` inchangée.
|
||
```php
|
||
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']);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lancer, vérifier l'échec** (signature à 3 args).
|
||
|
||
- [ ] **Step 3: Étendre `SidebarFilter`**
|
||
|
||
Ajouter le paramètre `array $activePermissions = []` à `filter()`. Mettre à jour la docblock des types (`permission?:string` sur section et item). Après le gate de rôle (section et item), ajouter le gate de permission :
|
||
```php
|
||
// Gate de permission au niveau section.
|
||
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
|
||
continue;
|
||
}
|
||
```
|
||
et pour l'item, avant le filtrage module :
|
||
```php
|
||
if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) {
|
||
continue;
|
||
}
|
||
```
|
||
Helper :
|
||
```php
|
||
/**
|
||
* @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);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Passer les permissions dans `SidebarProvider`**
|
||
|
||
Dans `provide()`, après `$roles = ...` :
|
||
```php
|
||
$permissions = ($user instanceof \App\Module\Core\Domain\Entity\User) ? $user->getEffectivePermissions() : [];
|
||
|
||
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions);
|
||
```
|
||
> Pour éviter le couplage dur au concret, préférer le contrat : `$user` peut être typé `UserInterface` (qui a désormais `getEffectivePermissions()`). Utiliser `$permissions = method_exists($user, 'getEffectivePermissions') ? $user->getEffectivePermissions() : [];` OU, plus propre, instancier-check sur `App\Shared\Domain\Contract\UserInterface`. Choisir le check sur le contrat Shared.
|
||
|
||
- [ ] **Step 5: Ajouter les permissions dans `config/sidebar.php`**
|
||
|
||
Sur la section admin, garder le gate `roles: [ROLE_ADMIN]` (rétrocompat) ET, en complément, ajouter une `permission` sur l'item Administration le cas échéant. Comme ROLE_ADMIN bypasse déjà tout (Décision 1), garder la section admin sur `roles` est suffisant ; on ajoute `permission` seulement si on veut donner accès à des non-admins porteurs de `core.users.view`/`core.roles.view`. Décider : ajouter `'permission' => 'core.users.view'` sur l'item `administration` pour permettre l'accès aux gestionnaires non-admin. Documenter dans le commentaire d'en-tête de `config/sidebar.php` la nouvelle clé `permission`.
|
||
> Mettre à jour le commentaire d'en-tête : « `permission` (section/item masqué si la permission effective absente — le RBAC fin) ».
|
||
|
||
- [ ] **Step 6: Tests + login + commit**
|
||
|
||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert.
|
||
Bloc « Vérification login » + vérifier `/api/sidebar` : `admin` voit Administration, `alice` (ROLE_USER sans permission) ne voit pas l'item gardé par permission.
|
||
```bash
|
||
make php-cs-fixer-allow-risky
|
||
git add -A -- src config tests
|
||
git commit -m "feat(core) : gate sidebar by effective permissions"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase G — Front : composable `usePermissions` + gestion des rôles
|
||
|
||
### Task 7: `usePermissions`, type user étendu, page admin rôles
|
||
|
||
**Files:**
|
||
- Create: `frontend/modules/core/composables/usePermissions.ts`
|
||
- Modify: type de l'utilisateur courant (chercher `frontend/services/dto/` ou store auth) — ajouter `effectivePermissions: string[]`
|
||
- Create: `frontend/modules/core/services/roles.ts` + `frontend/modules/core/services/permissions.ts`
|
||
- Create: `frontend/modules/core/pages/admin/roles.vue` (gestion des rôles) — OU onglet dans l'admin existant
|
||
- Modify: `frontend/i18n/locales/fr.json` (clés `admin.roles.*`)
|
||
|
||
> ⚠️ Le front Lesstime n'a pas encore de page de gestion de rôles. LIRE d'abord la structure (`frontend/modules/core/`, `frontend/stores/auth.ts`, `frontend/services/`, `frontend/pages/admin.vue` et ses onglets `Admin*Tab`). Reproduire le pattern existant (onglet `AdminUserTab` etc.). Le composable et le type sont le cœur de l'AC front ; la page de gestion peut être un onglet supplémentaire dans `admin.vue`.
|
||
|
||
**Interfaces:**
|
||
- Consumes : `/api/me.effectivePermissions`, `/api/roles`, `/api/permissions`.
|
||
- Produces : `usePermissions(): { can(code), canAny(codes), canAll(codes) }`.
|
||
|
||
- [ ] **Step 1: Étendre le type user + le store auth**
|
||
|
||
LIRE le store `frontend/stores/auth.ts` (ou `shared/stores/auth.ts`) et le DTO user. Ajouter `effectivePermissions: string[]` au type, et s'assurer que le payload `/api/me` (qui l'expose désormais, Phase C) est bien stocké. Si le type a `roles: string[]`, garder.
|
||
|
||
- [ ] **Step 2: Composable `usePermissions`**
|
||
|
||
`frontend/modules/core/composables/usePermissions.ts` :
|
||
```ts
|
||
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 }
|
||
}
|
||
```
|
||
> Le dossier `modules/core/composables` est auto-importé (scan `readdirSync('modules/')` → `imports.dirs`, cf. LST-62). `useAuthStore` est auto-importé.
|
||
|
||
- [ ] **Step 3: Services roles/permissions**
|
||
|
||
`frontend/modules/core/services/roles.ts` et `permissions.ts` : 1 service par ressource, via `useApi()` (pattern projet — LIRE `frontend/services/users.ts` comme modèle). Exposer `list()`, `create()`, `update()`, `remove()` pour roles ; `list()` pour permissions. Gérer la pagination via le pattern projet (ces collections sont bornées → `paginationEnabled: false` sur le `GetCollection` côté back, OU `fetchAllHydra`). Vérifier : si les collections Role/Permission peuvent dépasser 30 items, ajouter `paginationEnabled: false` sur leur `GetCollection` (cf. CLAUDE.md piège `extractHydraMembers`). Pour Lesstime, `permission` ≈ une douzaine de codes → borné ; `role` ≈ qqs unités → borné. Ajouter `paginationEnabled: false` sur les `GetCollection` de `Role` et `Permission` (entités, Phase A/D) pour fiabiliser `extractHydraMembers`.
|
||
|
||
- [ ] **Step 4: Page / onglet de gestion des rôles**
|
||
|
||
Ajouter un onglet `AdminRoleTab` dans `frontend/pages/admin.vue` (ou `modules/core`), listant les rôles, permettant création/édition (label, description, permissions cochées depuis `/api/permissions` groupées par module) et suppression (désactivée pour `isSystem`). Réserver l'affichage via `v-if="can('core.roles.view')"`. LIRE un `Admin*Tab` existant pour le style.
|
||
|
||
- [ ] **Step 5: i18n**
|
||
|
||
Ajouter dans `frontend/i18n/locales/fr.json` les clés `admin.roles.*` (titre, colonnes, actions, messages). Fusionner dans les namespaces existants (ne pas dupliquer une clé racine).
|
||
|
||
- [ ] **Step 6: Gate front + smoke**
|
||
|
||
Run: `cd frontend && npx nuxt build 2>&1 | grep -iE "error|roles|permission" | tail` — build OK, pas d'erreur de module manquant.
|
||
> Rappel LST-62 : `nuxt typecheck` n'est PAS un gate vert sur ce stack. Le vrai gate = build OK + aucun `Cannot find module` + auto-imports présents. Smoke navigateur (gestion des rôles) = PO.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A -- frontend
|
||
git commit -m "feat(core) : add usePermissions composable and rbac roles admin front"
|
||
```
|
||
|
||
---
|
||
|
||
## Acceptance check (après toutes les phases)
|
||
|
||
- [ ] **AC1** Permissions `module.resource.action` synchronisées via `app:sync-permissions` : la commande tourne sans erreur, `permission` contient les codes `core.*`.
|
||
- [ ] **AC2** Sidebar gated par permission ET par module actif : `/api/sidebar` masque les items selon `effectivePermissions` (et `disabledRoutes` selon module), ROLE_ADMIN voit tout.
|
||
- [ ] **AC3** `make test` vert (≈ 120 + nouveaux tests), `doctrine:schema:validate` mapping OK, `migrations:diff` = pas de changement RBAC (hors `messenger_messages`).
|
||
- [ ] `/api/me` expose `effectivePermissions`.
|
||
- [ ] `PermissionVoter` répond à `is_granted('core.*.*')` ; ROLE_ADMIN bypass.
|
||
- [ ] `app:seed-rbac` crée les rôles système `admin`/`user` (idempotent).
|
||
- [ ] Front : `usePermissions().can(code)` fonctionne ; gestion des rôles accessible aux porteurs de `core.roles.view`.
|
||
- [ ] Login/JWT/MCP inchangés (`204`/`200`/`200`) à chaque phase.
|
||
- [ ] `config/reference.php` jamais committé.
|
||
|
||
## Notes pour les tickets suivants
|
||
|
||
- **2.x (modules métier)** : chaque `*Module::permissions()` déclarera ses codes ; `app:sync-permissions` les upsertera ; les rôles métier (bureau/compta/…) seront seedés via une matrice étendue dans `RbacSeeder` quand les permissions existeront (Décision 4).
|
||
- **1.3 (Audit log)** : `core.audit_log.view` pourra être ajoutée à `CoreModule::permissions()` quand l'audit sera livré.
|
||
- **Migration `is_admin`** (optionnelle) : si le PO préfère le modèle Starseed, une phase ultérieure pourra remplacer le bypass `ROLE_ADMIN` par une colonne `is_admin` + data-migration (Décision 1).
|