feat(permissions) : add role-based access control system

Backend:
- Add role hierarchy (ADMIN > GESTIONNAIRE > VIEWER > USER) in security.yaml
- Add password authentication on profile activation (SessionProfileController)
- Add SessionProfileAuthenticator with stateless API firewall
- Add ProfilePasswordHasher state processor for API Platform
- Add security annotations on all 18 API Platform entities
- Add denyAccessUnlessGranted on all 13 custom controllers
- Add AdminProfileController for profile/role management (/api/admin/profiles)
- Add InitProfilePasswordsCommand for initial admin setup
- Simplify SessionProfilesController to list-only (removed create/delete)

Frontend (submodule update):
- Add usePermissions composable (isAdmin, canEdit, canView, isGranted)
- Add password login modal on profiles page
- Add admin backoffice page for profile management
- Disable all form fields for ROLE_VIEWER across all edit/create pages
- Show navigation buttons for all roles, hide destructive actions for viewers
- Add readonly mode to ModelTypeForm and site/constructeur modals
- Guard /admin routes in middleware
- Configure Vite proxy for API requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-26 13:37:12 +01:00
parent adc44b99d3
commit a3e440c254
36 changed files with 762 additions and 110 deletions

View File

@@ -8,9 +8,11 @@ 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 ApiPlatform\Metadata\Put;
use App\Repository\ProfileRepository;
use App\State\ProfilePasswordHasher;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -24,11 +26,24 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Put(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
processor: ProfilePasswordHasher::class,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['profile:read']],
denormalizationContext: ['groups' => ['profile:write']]
@@ -63,16 +78,21 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
* @var list<string> The user roles
*/
#[ORM\Column(type: 'json', options: ['default' => '["ROLE_USER"]'])]
#[Groups(['profile:read', 'profile:write'])]
#[Groups(['profile:read', 'profile:admin:write'])]
private array $roles = ['ROLE_USER'];
/**
* @var string The hashed password
* @var null|string The hashed password
*/
#[ORM\Column(type: 'string', nullable: true)]
#[Groups(['profile:write'])]
private ?string $password = null;
/**
* Non-persisted field used for password hashing via ProfilePasswordHasher.
*/
#[Groups(['profile:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable', name: 'createdat')]
#[Groups(['profile:read'])]
private DateTimeImmutable $createdAt;
@@ -83,7 +103,6 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
// Générer un CUID-like ID pour compatibilité avec Prisma
$this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
@@ -157,11 +176,10 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
return array_values(array_unique($roles));
}
/**
@@ -182,20 +200,37 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
return $this->password;
}
public function setPassword(string $password): static
public function setPassword(?string $password): static
{
$this->password = $password;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
#[Groups(['profile:read'])]
public function getHasPassword(): bool
{
return null !== $this->password && '' !== $this->password;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
$this->plainPassword = null;
}
public function getCreatedAt(): DateTimeImmutable