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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user