Files
Lesstime/docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md
T
matthieu 8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
Migration modular monolith DDD (0.1 → 3.3) (#17)
## 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
2026-06-23 13:50:42 +00:00

1691 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).