RBAC - Système complet de permissions (Backend + Frontend) (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
## Résumé
Implémentation complète du système RBAC (Role-Based Access Control) pour Coltura.
### Backend
- Entités Permission et Role avec API Platform CRUD
- PermissionVoter : vérification des permissions effectives (rôles + directes), admin bypass
- Endpoints `PATCH /users/{id}/rbac` pour assigner rôles, permissions directes et isAdmin
- AdminHeadcountGuard : protection contre la suppression du dernier admin
- Commande `app:sync-permissions` pour synchroniser les permissions déclarées par les modules
- Filtrage sidebar par permission RBAC (`permission` key optionnelle dans sidebar.php)
- 115 tests PHPUnit (fonctionnels + unitaires)
### Frontend
- Composable `usePermissions()` avec `can()`, `canAny()`, `canAll()` et admin bypass
- Page `/admin/roles` : DataTable, création/édition via drawer, suppression avec confirmation
- Page `/admin/users` : DataTable, drawer RBAC avec rôles, permissions directes, résumé effectif
- PermissionGroup : checkboxes groupées par module avec "tout sélectionner"
- EffectivePermissions : résumé lecture seule avec badges source ("via Rôle X" / "Direct")
- Warning auto-édition, toggle isAdmin
- Tests Vitest pour usePermissions
### Permissions déclarées
- `core.users.view` — Voir les utilisateurs
- `core.users.manage` — Gérer les utilisateurs
- `core.roles.view` — Voir les rôles RBAC
- `core.roles.manage` — Gérer les rôles et permissions
- `GET /api/permissions` accessible à tout utilisateur authentifié (catalogue read-only)
## Tickets Lesstime
- ERP-23 (#343) — Entités Permission et Role
- ERP-24 (#344) — API CRUD Roles & Permissions
- ERP-25 (#345) — Voter Symfony + usePermissions
- ERP-26 (#346) — Interface Admin : Gestion des Rôles
- ERP-27 (#347) — Interface Admin : Permissions Utilisateur
## Test plan
- [ ] `make db-reset` puis vérifier les fixtures (admin/alice/bob, rôles système)
- [ ] Login admin : sidebar affiche Gestion des rôles + Utilisateurs
- [ ] Login alice : sidebar masque ces onglets (pas de permission)
- [ ] Page /admin/roles : CRUD rôles, permissions groupées, protection rôles système
- [ ] Page /admin/users : assignation rôles + permissions directes, résumé effectif
- [ ] Warning auto-édition quand admin modifie ses propres droits
- [ ] `make test` : 115 tests PHPUnit passent
- [ ] `cd frontend && npm run test` : tests Vitest passent
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Matthieu <mtholot19@gmail.com>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Reviewed-on: MALIO-DEV/Coltura#7
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #7.
This commit is contained in:
@@ -9,4 +9,31 @@ final class CoreModule
|
||||
public const string ID = 'core';
|
||||
public const string LABEL = 'Core';
|
||||
public const bool REQUIRED = true;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Core.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||
* orphelins ceux qui ont disparu du code source.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||
* snake_case, le prefixe module devant correspondre exactement a
|
||||
* `self::ID` (verifie par la commande de synchronisation).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
|
||||
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
|
||||
['code' => 'core.roles.view', 'label' => 'Voir les roles RBAC'],
|
||||
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
153
src/Module/Core/Domain/Entity/Permission.php
Normal file
153
src/Module/Core/Domain/Entity/Permission.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['permission:read']],
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['module' => 'exact'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||
#[ORM\Table(name: 'permission')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||
class Permission
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['permission:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['permission:read'])]
|
||||
private string $module;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['permission:read'])]
|
||||
private bool $orphan = false;
|
||||
|
||||
/**
|
||||
* Invariants : une permission doit avoir un code non vide respectant la
|
||||
* convention "module.resource[.sub].action" (donc contenir au moins un
|
||||
* point), un libelle non vide et un module proprietaire non vide. Ces
|
||||
* garde-fous evitent la persistence de lignes incoherentes si un appelant
|
||||
* (fixture, commande de synchro, import) oublie un champ ou passe une
|
||||
* chaine vide.
|
||||
*/
|
||||
public function __construct(string $code, string $label, string $module)
|
||||
{
|
||||
if ('' === $code) {
|
||||
throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.');
|
||||
}
|
||||
if (!str_contains($code, '.')) {
|
||||
throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code));
|
||||
}
|
||||
if ('' === $label) {
|
||||
throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.');
|
||||
}
|
||||
if ('' === $module) {
|
||||
throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque la permission comme orpheline : son code n'est plus declare par
|
||||
* aucun module. Elle reste en base pour preserver les assignations et
|
||||
* permettre une reactivation ulterieure, mais doit etre ignoree par les
|
||||
* verifications d'autorisation.
|
||||
*/
|
||||
public function markOrphan(): static
|
||||
{
|
||||
$this->orphan = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive une permission precedemment orpheline : son code reapparait
|
||||
* dans le code source d'un module. Equivaut a updateMetadata() suivi d'un
|
||||
* clearing du flag orphan ; on delegue a updateMetadata() pour ne pas
|
||||
* dupliquer la logique d'affectation des metadonnees.
|
||||
*/
|
||||
public function revive(string $label, string $module): static
|
||||
{
|
||||
$this->updateMetadata($label, $module);
|
||||
$this->orphan = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour les metadonnees d'une permission active sans toucher a son
|
||||
* statut d'orphelin. Utilise par la commande de synchronisation lorsque
|
||||
* seul le libelle ou le module proprietaire a change cote code.
|
||||
*/
|
||||
public function updateMetadata(string $label, string $module): static
|
||||
{
|
||||
$this->label = $label;
|
||||
$this->module = $module;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
233
src/Module/Core/Domain/Entity/Role.php
Normal file
233
src/Module/Core/Domain/Entity/Role.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
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\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
|
||||
*
|
||||
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
|
||||
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
|
||||
* peuvent etre supprimes.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
security: "is_granted('core.roles.view')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
security: "is_granted('core.roles.manage')",
|
||||
processor: RoleProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['role:read']],
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
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']],
|
||||
)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||
#[ORM\Table(name: '`role`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
||||
class Role
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['role:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')]
|
||||
private string $code;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
#[Assert\NotBlank]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['role:read', 'role:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
// Volontairement exclu du groupe `role:write` : un client ne doit jamais
|
||||
// pouvoir positionner ce flag via l'API. Seules les fixtures et migrations
|
||||
// creent les roles systeme.
|
||||
#[ORM\Column(name: 'is_system', options: ['default' => false])]
|
||||
#[Groups(['role:read'])]
|
||||
private bool $isSystem = false;
|
||||
|
||||
/** @var Collection<int, Permission> */
|
||||
// Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
|
||||
// - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
|
||||
// serialisation hors contexte EntityManager (voir ticket #343, section
|
||||
// 11 risque #1) ou la collection serait inaccessible et provoquerait
|
||||
// une erreur opaque.
|
||||
// - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
|
||||
// d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
|
||||
// - Si la volumetrie augmente significativement : revoir vers une
|
||||
// projection cachee (ticket a ouvrir a ce moment-la).
|
||||
#[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, bool $isSystem = false, ?string $description = null)
|
||||
{
|
||||
$this->code = $code;
|
||||
$this->label = $label;
|
||||
$this->isSystem = $isSystem;
|
||||
$this->description = $description;
|
||||
$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 getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
// Le getter est annote directement car la convention Symfony PropertyInfo
|
||||
// strip le prefixe `is` et exposerait le champ sous le nom `system`. On
|
||||
// pose donc un SerializedName explicite pour garantir la sortie JSON-LD
|
||||
// sous `isSystem`, nom attendu par les clients de l'API.
|
||||
#[Groups(['role:read'])]
|
||||
#[SerializedName('isSystem')]
|
||||
public function isSystem(): bool
|
||||
{
|
||||
return $this->isSystem;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Permission> */
|
||||
public function getPermissions(): Collection
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter expose uniquement a la denormalisation API Platform pour
|
||||
* permettre au RoleProcessor de detecter une tentative de modification
|
||||
* du code (garde "code immuable"). Le code reste en pratique fige apres
|
||||
* creation : le processor refuse toute modification via 400.
|
||||
*
|
||||
* @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes.
|
||||
* Hors contexte API Platform, cette methode modifie silencieusement
|
||||
* le code sans aucun garde.
|
||||
*/
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour le libelle affichable du role. Le code reste immuable pour
|
||||
* garantir la stabilite des references cote fixtures et migrations.
|
||||
*/
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour la description libre du role (champ documentaire).
|
||||
*/
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une permission au role. Idempotent : ajouter deux fois la meme
|
||||
* permission n'entraine pas de doublon dans la collection.
|
||||
*/
|
||||
public function addPermission(Permission $permission): static
|
||||
{
|
||||
if (!$this->permissions->contains($permission)) {
|
||||
$this->permissions->add($permission);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une permission du role. Idempotent : retirer une permission
|
||||
* absente est un no-op silencieux.
|
||||
*/
|
||||
public function removePermission(Permission $permission): static
|
||||
{
|
||||
$this->permissions->removeElement($permission);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde domaine : refuse la suppression d'un role marque comme systeme.
|
||||
* La traduction HTTP (403) est faite au niveau application / API Platform.
|
||||
*/
|
||||
public function ensureDeletable(): void
|
||||
{
|
||||
if ($this->isSystem) {
|
||||
throw SystemRoleDeletionException::forRole($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,18 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -27,14 +32,24 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('core.users.view')",
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new GetCollection(
|
||||
security: "is_granted('core.users.view')",
|
||||
normalizationContext: ['groups' => ['user:list']],
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(
|
||||
name: 'user_rbac_patch',
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('core.users.manage')",
|
||||
normalizationContext: ['groups' => ['user:rbac:read']],
|
||||
denormalizationContext: ['groups' => ['user:rbac:write']],
|
||||
processor: UserRbacProcessor::class,
|
||||
),
|
||||
new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
@@ -45,17 +60,52 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read', 'user:list'])]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180, unique: true)]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?string $username = null;
|
||||
|
||||
/** @var list<string> */
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private array $roles = [];
|
||||
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
|
||||
// Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac.
|
||||
// Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer
|
||||
// la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName).
|
||||
#[Groups(['user:rbac:write'])]
|
||||
private bool $isAdmin = false;
|
||||
|
||||
/**
|
||||
* Les roles RBAC metier rattaches a l'utilisateur.
|
||||
*
|
||||
* Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
|
||||
* un refresh de token JWT ou une serialisation hors contexte EntityManager
|
||||
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
|
||||
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
|
||||
*
|
||||
* @var Collection<int, Role>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_role')]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||
// La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
|
||||
// collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
|
||||
// on reexpose la cle JSON sous `roles` via SerializedName pour rester
|
||||
// conforme au contrat API documente dans le ticket #344.
|
||||
#[SerializedName('roles')]
|
||||
private Collection $rbacRoles;
|
||||
|
||||
/**
|
||||
* Les permissions directes accordees hors des roles.
|
||||
*
|
||||
* Meme justification EAGER que pour $rbacRoles : garantie que
|
||||
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
|
||||
*
|
||||
* @var Collection<int, Permission>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'user_permission')]
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||
private Collection $directPermissions;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $password = null;
|
||||
@@ -68,7 +118,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->rbacRoles = new ArrayCollection();
|
||||
$this->directPermissions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -93,23 +145,134 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* Retourne les roles Symfony Security, derives de $isAdmin.
|
||||
*
|
||||
* ROLE_USER est toujours present pour que Symfony accepte l'authentification.
|
||||
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
|
||||
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
|
||||
*
|
||||
* Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
|
||||
* ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
|
||||
* la Collection peut ne pas etre hydratee. On se contente d'un calcul
|
||||
* base sur un scalaire.
|
||||
*
|
||||
* @see getRbacRoles() pour la collection RBAC metier (exposee en JSON sous la cle "roles").
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
$roles = ['ROLE_USER'];
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
if ($this->isAdmin) {
|
||||
$roles[] = 'ROLE_ADMIN';
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/** @param list<string> $roles */
|
||||
public function setRoles(array $roles): static
|
||||
// Groupes de lecture + nom serialise explicite pour eviter que Symfony
|
||||
// ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin".
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:read'])]
|
||||
#[SerializedName('isAdmin')]
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
$this->roles = $roles;
|
||||
return $this->isAdmin;
|
||||
}
|
||||
|
||||
public function setIsAdmin(bool $isAdmin): static
|
||||
{
|
||||
$this->isAdmin = $isAdmin;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la collection de roles RBAC rattaches a l'utilisateur.
|
||||
*
|
||||
* NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
|
||||
*
|
||||
* @return Collection<int, Role>
|
||||
*/
|
||||
public function getRbacRoles(): Collection
|
||||
{
|
||||
return $this->rbacRoles;
|
||||
}
|
||||
|
||||
public function addRbacRole(Role $role): static
|
||||
{
|
||||
if (!$this->rbacRoles->contains($role)) {
|
||||
$this->rbacRoles->add($role);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeRbacRole(Role $role): static
|
||||
{
|
||||
$this->rbacRoles->removeElement($role);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Permission>
|
||||
*/
|
||||
public function getDirectPermissions(): Collection
|
||||
{
|
||||
return $this->directPermissions;
|
||||
}
|
||||
|
||||
public function addDirectPermission(Permission $permission): static
|
||||
{
|
||||
if (!$this->directPermissions->contains($permission)) {
|
||||
$this->directPermissions->add($permission);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeDirectPermission(Permission $permission): static
|
||||
{
|
||||
$this->directPermissions->removeElement($permission);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'union dedupliquee des codes de permissions effectives.
|
||||
*
|
||||
* Agrege les permissions venant des roles RBAC et les permissions directes.
|
||||
* Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
|
||||
* apres l'evolution du MeProvider (aussi ticket #345).
|
||||
*
|
||||
* Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
|
||||
* methode pour le piege de chargement au refresh JWT.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
#[Groups(['me: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;
|
||||
}
|
||||
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Levee lorsqu'une operation mettrait fin a la presence d'au moins un
|
||||
* administrateur sur l'instance.
|
||||
*
|
||||
* L'invariant "au moins un admin doit exister" est protege au niveau du
|
||||
* domaine afin qu'aucun flux (API, CLI, import) ne puisse le contourner.
|
||||
* La traduction HTTP (422 ou 403) est laissee a la couche infrastructure.
|
||||
*/
|
||||
final class LastAdminProtectionException extends DomainException
|
||||
{
|
||||
/**
|
||||
* Construit l'exception avec un message par defaut ou un message fourni par l'appelant.
|
||||
*/
|
||||
public function __construct(string $message = 'Impossible : au moins un administrateur doit rester sur l\'instance.')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Exception;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Levee lorsqu'une tentative de suppression vise un role marque comme systeme.
|
||||
*
|
||||
* Les roles systeme (ex : admin, user) sont proteges au niveau du domaine
|
||||
* pour garantir qu'ils ne peuvent jamais etre retires par un administrateur,
|
||||
* une commande ou un processus d'import. La traduction HTTP (403) est faite
|
||||
* ailleurs, cette exception reste purement domaine.
|
||||
*/
|
||||
final class SystemRoleDeletionException extends DomainException
|
||||
{
|
||||
/**
|
||||
* Construit l'exception a partir du role refuse a la suppression.
|
||||
*/
|
||||
public static function forRole(Role $role): self
|
||||
{
|
||||
return new self(sprintf('Le role systeme "%s" ne peut pas etre supprime.', $role->getCode()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Repository;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
|
||||
/**
|
||||
* Contrat du catalogue de permissions RBAC.
|
||||
*
|
||||
* Utilise par la commande de synchronisation (app:sync-permissions), les
|
||||
* fixtures, et — a terme (ticket #345) — par le PermissionVoter pour valider
|
||||
* que les codes verifies existent bien dans le catalogue.
|
||||
*/
|
||||
interface PermissionRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Permission;
|
||||
|
||||
public function findByCode(string $code): ?Permission;
|
||||
|
||||
/**
|
||||
* @return array<int, Permission>
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* @return array<int, string> liste des codes connus, pour la commande de sync et le futur voter
|
||||
*/
|
||||
public function findAllCodes(): array;
|
||||
|
||||
public function save(Permission $permission): void;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Repository;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
|
||||
/**
|
||||
* Contrat des roles RBAC.
|
||||
*
|
||||
* Utilise par les fixtures, la future API d'administration (ticket #344) et
|
||||
* le PermissionVoter pour resoudre les permissions effectives d'un role.
|
||||
*/
|
||||
interface RoleRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Role;
|
||||
|
||||
public function findByCode(string $code): ?Role;
|
||||
|
||||
/**
|
||||
* @return array<int, Role>
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
public function save(Role $role): void;
|
||||
}
|
||||
@@ -13,4 +13,12 @@ interface UserRepositoryInterface
|
||||
public function findByUsername(string $username): ?User;
|
||||
|
||||
public function save(User $user): void;
|
||||
|
||||
/**
|
||||
* Retourne le nombre d'utilisateurs ayant le flag isAdmin a true.
|
||||
*
|
||||
* Utilise par AdminHeadcountGuard pour verifier l'invariant
|
||||
* "au moins un administrateur doit rester sur l'instance".
|
||||
*/
|
||||
public function countAdmins(): int;
|
||||
}
|
||||
|
||||
71
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
71
src/Module/Core/Domain/Security/AdminHeadcountGuard.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Gardien de l'invariant domaine : l'instance doit toujours conserver
|
||||
* au moins un utilisateur administrateur.
|
||||
*
|
||||
* Ce service est appele avant toute operation susceptible de reduire le
|
||||
* nombre d'admins (retrait du flag isAdmin, suppression d'un utilisateur).
|
||||
* Il compte les admins restants et leve LastAdminProtectionException si
|
||||
* le seuil minimum (1) serait franchi.
|
||||
*/
|
||||
final class AdminHeadcountGuard implements AdminHeadcountGuardInterface
|
||||
{
|
||||
public function __construct(private readonly UserRepositoryInterface $userRepository) {}
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||
*
|
||||
* L'argument $user est accepte mais non utilise dans la logique de comptage :
|
||||
* l'appelant a deja determine que cet utilisateur va perdre son statut admin ;
|
||||
* le garde se contente de verifier qu'il en reste au moins un autre.
|
||||
* Le parametre est conserve pour la lisibilite du site d'appel et pour
|
||||
* permettre une evolution future (ex : journalisation, audit trail).
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void
|
||||
{
|
||||
$this->checkAdminHeadcount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||
*
|
||||
* Meme principe que ensureAtLeastOneAdminRemainsAfterDemotion() : $user
|
||||
* est accepte pour la symetrie du contrat et les evolutions futures,
|
||||
* mais le comptage ne depend pas de son identite.
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void
|
||||
{
|
||||
$this->checkAdminHeadcount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les administrateurs et leve une exception si le seuil minimum est atteint.
|
||||
*
|
||||
* La verification est volontairement conservative (<=1) pour couvrir
|
||||
* le cas defensif ou la base serait deja dans un etat incoherent (0 admin).
|
||||
*
|
||||
* TOCTOU accepte : la verification n'utilise pas de verrou pessimiste
|
||||
* (SELECT ... FOR UPDATE). Deux demotions concurrentes pourraient donc
|
||||
* passer le garde simultanement. Ce risque est accepte dans le contexte
|
||||
* PME/CRM ou les operations d'administration sont rares et mono-operateur.
|
||||
* Si la concurrence admin devient un enjeu, ajouter un verrou pessimiste
|
||||
* sur countAdmins() ou une contrainte CHECK en base.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le nombre d'admins est inferieur ou egal a 1
|
||||
*/
|
||||
private function checkAdminHeadcount(): void
|
||||
{
|
||||
if ($this->userRepository->countAdmins() <= 1) {
|
||||
throw new LastAdminProtectionException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
|
||||
/**
|
||||
* Contrat du gardien de l'invariant "au moins un admin sur l'instance".
|
||||
*
|
||||
* Separer l'interface de l'implementation permet de tester unitairement
|
||||
* les processors qui dependent de ce garde sans instancier le repository.
|
||||
*/
|
||||
interface AdminHeadcountGuardInterface
|
||||
{
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la demote de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDemotion(User $user): void;
|
||||
|
||||
/**
|
||||
* Verifie qu'il restera au moins un admin apres la suppression de $user.
|
||||
*
|
||||
* @throws LastAdminProtectionException si le seuil minimum serait franchi
|
||||
*/
|
||||
public function ensureAtLeastOneAdminRemainsAfterDeletion(User $user): void;
|
||||
}
|
||||
23
src/Module/Core/Domain/Security/SystemRoles.php
Normal file
23
src/Module/Core/Domain/Security/SystemRoles.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Security;
|
||||
|
||||
/**
|
||||
* Source de verite unique pour les codes des roles systeme RBAC.
|
||||
*
|
||||
* Ces constantes sont partagees entre les fixtures applicatives et les
|
||||
* migrations Doctrine (qui inserent les memes codes en SQL brut). Toute
|
||||
* modification ici doit etre repercutee dans la migration correspondante.
|
||||
*/
|
||||
final class SystemRoles
|
||||
{
|
||||
public const string ADMIN_CODE = 'admin';
|
||||
public const string USER_CODE = 'user';
|
||||
|
||||
/**
|
||||
* Empeche l'instanciation : cette classe est un simple porteur de constantes.
|
||||
*/
|
||||
private function __construct() {}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?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 App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor applicatif pour l'entite Role.
|
||||
*
|
||||
* Choix d'implementation : une seule classe qui recoit en dependances les deux
|
||||
* processors Doctrine decores (Persist et Remove) et branche l'un ou l'autre
|
||||
* selon le type d'operation. Ce choix reste plus lisible que deux classes
|
||||
* jumelees et reflete la symetrie des gardes metier (immuabilite du `code`
|
||||
* cote ecriture, protection des roles systeme cote suppression).
|
||||
*
|
||||
* Gardes metier :
|
||||
* - DELETE : delegue a Role::ensureDeletable() et traduit la
|
||||
* SystemRoleDeletionException en AccessDeniedHttpException (403).
|
||||
* - POST/PATCH : refuse toute modification du `code` (champ immuable apres
|
||||
* creation), regle uniforme pour les roles systeme ET custom.
|
||||
*
|
||||
* @implements ProcessorInterface<Role, null|Role>
|
||||
*/
|
||||
final class RoleProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Role) {
|
||||
// Ce processor est wire exclusivement sur les operations Role.
|
||||
// Si on arrive ici avec autre chose, c'est une misconfiguration
|
||||
// qu'il faut faire remonter fort.
|
||||
throw new LogicException(sprintf(
|
||||
'RoleProcessor attend une instance de %s, %s recu.',
|
||||
Role::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
try {
|
||||
$data->ensureDeletable();
|
||||
} catch (SystemRoleDeletionException $e) {
|
||||
// Traduction HTTP : le domaine reste pur, l'API renvoie 403.
|
||||
throw new AccessDeniedHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Ecriture (POST/PATCH) : verifier l'immuabilite du `code`.
|
||||
// L'UnitOfWork n'expose un etat d'origine que pour les entites deja
|
||||
// managees (PATCH). Pour un POST (entite nouvelle), `getOriginalEntityData`
|
||||
// retourne un tableau vide : aucune comparaison necessaire.
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
|
||||
if (isset($originalData['code']) && $originalData['code'] !== $data->getCode()) {
|
||||
throw new BadRequestHttpException("Le code d'un role est immuable apres creation.");
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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 App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor dedie a l'operation `DELETE /api/users/{id}`.
|
||||
*
|
||||
* Delegue la suppression au RemoveProcessor Doctrine decore apres avoir
|
||||
* applique la garde "dernier admin global" : si l'utilisateur cible est
|
||||
* le seul admin restant sur l'instance, la suppression est refusee pour
|
||||
* preserver l'invariant "au moins un administrateur reste toujours".
|
||||
*
|
||||
* La garde est portee par AdminHeadcountGuard (domaine), partagee avec
|
||||
* UserRbacProcessor qui gere le meme invariant sur le chemin PATCH /rbac.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final class UserProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof User) {
|
||||
// Ce processor est wire exclusivement sur l'operation Delete de User.
|
||||
// Si on arrive ici avec un autre type, c'est une misconfiguration.
|
||||
throw new LogicException(sprintf(
|
||||
'UserProcessor attend une instance de %s, %s recu.',
|
||||
User::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde dernier admin global : on ne verifie que si on supprime
|
||||
// effectivement un admin. La suppression d'un user standard n'a
|
||||
// aucun impact sur le compteur d'administrateurs.
|
||||
if ($data->isAdmin()) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDeletion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?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 App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor dedie a l'endpoint RBAC `PATCH /api/users/{id}/rbac`.
|
||||
*
|
||||
* Delegue la persistance au PersistProcessor Doctrine decore apres avoir
|
||||
* applique les gardes metier propres aux changements de droits. Cet endpoint
|
||||
* ne touche JAMAIS au mot de passe — c'est une separation volontaire avec le
|
||||
* UserPasswordHasherProcessor qui gere le endpoint profil `/api/users/{id}`.
|
||||
*
|
||||
* Gardes metier (dans l'ordre d'execution) :
|
||||
* - Auto-suicide : un admin ne peut pas retirer son propre flag `isAdmin`.
|
||||
* Cas particulier plus strict, avec message dedie.
|
||||
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
||||
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
||||
* AdminHeadcountGuardInterface.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final class UserRbacProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
private readonly AdminHeadcountGuardInterface $adminHeadcountGuard,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof User) {
|
||||
// Ce processor est wire exclusivement sur l'operation user_rbac_patch
|
||||
// qui cible User. Si on arrive ici avec autre chose, c'est une
|
||||
// misconfiguration qu'il faut faire remonter fort.
|
||||
throw new LogicException(sprintf(
|
||||
'UserRbacProcessor attend une instance de %s, %s recu.',
|
||||
User::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
$currentUser = $this->security->getUser();
|
||||
|
||||
// Calcul partage entre les deux gardes : l'user perdait-il le flag admin ?
|
||||
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
|
||||
$wasAdmin = $originalData['isAdmin'] ?? null;
|
||||
$willLoseAdmin = true === $wasAdmin && false === $data->isAdmin();
|
||||
|
||||
// Garde auto-suicide : cas particulier plus strict — l'user courant ne
|
||||
// peut pas retirer son propre flag admin, meme si d'autres admins existent.
|
||||
if ($willLoseAdmin && $currentUser instanceof User && $currentUser->getId() === $data->getId()) {
|
||||
throw new BadRequestHttpException(
|
||||
'Vous ne pouvez pas retirer vos propres droits administrateur.'
|
||||
);
|
||||
}
|
||||
|
||||
// Garde dernier admin global : invariant general — impossible de retirer
|
||||
// isAdmin si cela laisserait l'instance sans administrateur.
|
||||
if ($willLoseAdmin) {
|
||||
try {
|
||||
$this->adminHeadcountGuard->ensureAtLeastOneAdminRemainsAfterDemotion($data);
|
||||
} catch (LastAdminProtectionException $exception) {
|
||||
throw new BadRequestHttpException($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<object>
|
||||
@@ -13,7 +14,7 @@ use ApiPlatform\State\ProviderInterface;
|
||||
class MeProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \Symfony\Bundle\SecurityBundle\Security $security,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
|
||||
|
||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
use App\Module\Core\Domain\Security\SystemRoles;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -17,13 +19,14 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:create-user',
|
||||
description: 'Create a new user',
|
||||
description: 'Cree un utilisateur rattache au role systeme admin ou user.',
|
||||
)]
|
||||
class CreateUserCommand extends Command
|
||||
final class CreateUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RoleRepositoryInterface $roleRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -31,9 +34,9 @@ class CreateUserCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('username', InputArgument::REQUIRED, 'Username')
|
||||
->addArgument('password', InputArgument::REQUIRED, 'Plain password')
|
||||
->addOption('admin', null, InputOption::VALUE_NONE, 'Grant ROLE_ADMIN')
|
||||
->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur')
|
||||
->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair')
|
||||
->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin')
|
||||
;
|
||||
}
|
||||
|
||||
@@ -43,18 +46,34 @@ class CreateUserCommand extends Command
|
||||
|
||||
$username = $input->getArgument('username');
|
||||
$plainPassword = $input->getArgument('password');
|
||||
$isAdmin = (bool) $input->getOption('admin');
|
||||
|
||||
$roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE;
|
||||
$role = $this->roleRepository->findByCode($roleCode);
|
||||
|
||||
if (null === $role) {
|
||||
$io->error(sprintf(
|
||||
'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.',
|
||||
$roleCode,
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||
|
||||
if ($input->getOption('admin')) {
|
||||
$user->setRoles(['ROLE_ADMIN']);
|
||||
}
|
||||
$user->setIsAdmin($isAdmin);
|
||||
$user->addRbacRole($role);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : ''));
|
||||
$io->success(sprintf(
|
||||
'Utilisateur "%s" cree, rattache au role systeme "%s"%s.',
|
||||
$username,
|
||||
$roleCode,
|
||||
$isAdmin ? ' (bypass is_admin actif)' : '',
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?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 Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Throwable;
|
||||
|
||||
use function count;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:sync-permissions',
|
||||
description: 'Synchronise les permissions RBAC declarees par les modules actifs.',
|
||||
)]
|
||||
final class SyncPermissionsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
// Etape 1 : scan + validation stricte des modules actifs AVANT
|
||||
// tout acces en ecriture a la base, afin qu'une erreur de
|
||||
// declaration laisse la table `permission` intacte.
|
||||
$desiredPermissions = $this->collectDesiredPermissions();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Etape 2 : upsert transactionnel non destructif.
|
||||
$this->em->beginTransaction();
|
||||
|
||||
try {
|
||||
// Indexation des permissions existantes par code pour un acces O(1).
|
||||
$existingByCode = [];
|
||||
foreach ($this->permissionRepository->findAll() as $permission) {
|
||||
$existingByCode[$permission->getCode()] = $permission;
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
$updated = 0;
|
||||
$orphans = 0;
|
||||
|
||||
// Upsert : chaque entree desiree est creee, revivee ou mise a jour.
|
||||
foreach ($desiredPermissions as $code => $entry) {
|
||||
$label = $entry['label'];
|
||||
$module = $entry['module'];
|
||||
|
||||
if (isset($existingByCode[$code])) {
|
||||
$existing = $existingByCode[$code];
|
||||
|
||||
if ($existing->isOrphan()) {
|
||||
// Revival : le code reapparait dans le source, on
|
||||
// rafraichit ses metadonnees et on retire le flag.
|
||||
$existing->revive($label, $module);
|
||||
++$updated;
|
||||
} elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) {
|
||||
// Mise a jour des metadonnees sans toucher au flag orphan.
|
||||
$existing->updateMetadata($label, $module);
|
||||
++$updated;
|
||||
}
|
||||
// Sinon : strictement identique, no-op.
|
||||
} else {
|
||||
// Creation : on persiste directement via l'EM pour ne
|
||||
// pas declencher un flush par appel (cf. save() repo).
|
||||
$permission = new Permission($code, $label, $module);
|
||||
$this->em->persist($permission);
|
||||
++$added;
|
||||
}
|
||||
}
|
||||
|
||||
// Etape 3 : marquage orphelin des permissions absentes du source.
|
||||
foreach ($existingByCode as $code => $existing) {
|
||||
if (isset($desiredPermissions[$code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$existing->isOrphan()) {
|
||||
$existing->markOrphan();
|
||||
++$orphans;
|
||||
}
|
||||
}
|
||||
|
||||
// Un unique flush regroupe toutes les mutations de la transaction.
|
||||
$this->em->flush();
|
||||
$this->em->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->em->rollback();
|
||||
$io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$totalInDb = count($this->permissionRepository->findAll());
|
||||
|
||||
$io->success('Synchronisation des permissions RBAC terminee.');
|
||||
$io->table(
|
||||
['Indicateur', 'Valeur'],
|
||||
[
|
||||
['Permissions ajoutees', (string) $added],
|
||||
['Permissions mises a jour ou revivees', (string) $updated],
|
||||
['Permissions marquees orphelines', (string) $orphans],
|
||||
['Total en base apres sync', (string) $totalInDb],
|
||||
],
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt la liste des modules actifs declares dans `config/modules.php`,
|
||||
* extrait leurs permissions statiques, valide strictement chaque entree
|
||||
* puis renvoie une map indexee par code.
|
||||
*
|
||||
* Regles de validation appliquees :
|
||||
* - chaque entree doit posseder exactement les cles `code` et `label`
|
||||
* - le `code` doit etre prefixe par `<ModuleClass>::ID . '.'`
|
||||
* - `code` et `label` ne peuvent pas etre des chaines vides
|
||||
*
|
||||
* Les modules ne definissant pas de methode statique `permissions()` sont
|
||||
* ignores silencieusement (compat ascendante pour les modules legacy).
|
||||
*
|
||||
* @return array<string, array{code: string, label: string, module: string}>
|
||||
*/
|
||||
private function collectDesiredPermissions(): array
|
||||
{
|
||||
/** @var array<int, class-string> $moduleClasses */
|
||||
$moduleClasses = require $this->projectDir.'/config/modules.php';
|
||||
|
||||
$desired = [];
|
||||
|
||||
foreach ($moduleClasses as $moduleClass) {
|
||||
if (!method_exists($moduleClass, 'permissions')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, string>> $entries */
|
||||
$entries = $moduleClass::permissions();
|
||||
$moduleId = $moduleClass::ID;
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$keys = array_keys($entry);
|
||||
sort($keys);
|
||||
if (['code', 'label'] !== $keys) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].',
|
||||
$moduleClass,
|
||||
implode(', ', array_keys($entry)),
|
||||
));
|
||||
}
|
||||
|
||||
$code = $entry['code'];
|
||||
$label = $entry['label'];
|
||||
|
||||
if ('' === $code) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Permission invalide declaree par %s : le code ne peut pas etre vide.',
|
||||
$moduleClass,
|
||||
));
|
||||
}
|
||||
if ('' === $label) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.',
|
||||
$moduleClass,
|
||||
$code,
|
||||
));
|
||||
}
|
||||
|
||||
$expectedPrefix = $moduleId.'.';
|
||||
if (!str_starts_with($code, $expectedPrefix)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).',
|
||||
$moduleClass,
|
||||
$code,
|
||||
$expectedPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
$desired[$code] = [
|
||||
'code' => $code,
|
||||
'label' => $label,
|
||||
'module' => $moduleId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $desired;
|
||||
}
|
||||
}
|
||||
@@ -4,37 +4,90 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||
use App\Module\Core\Domain\Security\SystemRoles;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
|
||||
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
|
||||
*
|
||||
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
|
||||
* entites managees, ce qui inclut la table role. On re-seede donc les roles
|
||||
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
||||
* que le workflow "make db-reset && make fixtures" reste one-shot.
|
||||
*/
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RoleRepositoryInterface $roleRepository,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$adminRole = $this->ensureSystemRole(
|
||||
$manager,
|
||||
SystemRoles::ADMIN_CODE,
|
||||
'Administrateur',
|
||||
'Role administrateur - bypass complet via is_admin',
|
||||
);
|
||||
$userRole = $this->ensureSystemRole(
|
||||
$manager,
|
||||
SystemRoles::USER_CODE,
|
||||
'Utilisateur',
|
||||
'Role de base sans permission specifique',
|
||||
);
|
||||
|
||||
$admin = new User();
|
||||
$admin->setUsername('admin');
|
||||
$admin->setRoles(['ROLE_ADMIN']);
|
||||
$admin->setIsAdmin(true);
|
||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||
$admin->addRbacRole($adminRole);
|
||||
$manager->persist($admin);
|
||||
|
||||
$alice = new User();
|
||||
$alice->setUsername('alice');
|
||||
$alice->setRoles(['ROLE_USER']);
|
||||
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
||||
$alice->addRbacRole($userRole);
|
||||
$manager->persist($alice);
|
||||
|
||||
$bob = new User();
|
||||
$bob->setUsername('bob');
|
||||
$bob->setRoles(['ROLE_USER']);
|
||||
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
||||
$bob->addRbacRole($userRole);
|
||||
$manager->persist($bob);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le role systeme correspondant au code donne, en le creant
|
||||
* s'il n'existe pas encore (le purger Doctrine a pu vider la table role).
|
||||
*
|
||||
* La description est recopiee depuis la migration RBAC pour que les
|
||||
* deux chemins (migration prod, fixtures dev) produisent un etat
|
||||
* identique.
|
||||
*/
|
||||
private function ensureSystemRole(
|
||||
ObjectManager $manager,
|
||||
string $code,
|
||||
string $label,
|
||||
string $description,
|
||||
): Role {
|
||||
$role = $this->roleRepository->findByCode($code);
|
||||
|
||||
if (null !== $role) {
|
||||
return $role;
|
||||
}
|
||||
|
||||
$role = new Role($code, $label, isSystem: true, description: $description);
|
||||
$manager->persist($role);
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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>
|
||||
*/
|
||||
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 array<int, Permission>
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
return parent::findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function findAllCodes(): array
|
||||
{
|
||||
// Requete legere : on ne selectionne que la colonne code (pas d'hydratation
|
||||
// d'entites Permission) car findAllCodes() est appelee par la commande de
|
||||
// sync et le futur voter qui n'ont besoin que des chaines.
|
||||
$rows = $this->createQueryBuilder('p')
|
||||
->select('p.code')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return array_column($rows, 'code');
|
||||
}
|
||||
|
||||
public function save(Permission $permission): void
|
||||
{
|
||||
$this->getEntityManager()->persist($permission);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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>
|
||||
*/
|
||||
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 array<int, Role>
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
return parent::findAll();
|
||||
}
|
||||
|
||||
public function save(Role $role): void
|
||||
{
|
||||
$this->getEntityManager()->persist($role);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,20 @@ class DoctrineUserRepository extends ServiceEntityRepository implements UserRepo
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les utilisateurs ayant le flag isAdmin a true.
|
||||
*
|
||||
* Utilise par AdminHeadcountGuard pour verifier que l'instance conserve
|
||||
* toujours au moins un administrateur apres une demote ou une suppression.
|
||||
*/
|
||||
public function countAdmins(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('u')
|
||||
->select('COUNT(u.id)')
|
||||
->where('u.isAdmin = true')
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
66
src/Module/Core/Infrastructure/Security/PermissionVoter.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter RBAC qui evalue les codes de permission metier au format
|
||||
* "module.resource.action" (ex: "core.users.view").
|
||||
*
|
||||
* - Ignore silencieusement les attributs non-RBAC (ROLE_*, IS_AUTHENTICATED_*, ...),
|
||||
* qui restent traites par les voters core de Symfony. Strategy 'affirmative'
|
||||
* par defaut : tant qu'un voter repond GRANTED, l'acces est accorde.
|
||||
* - Bypass total si l'utilisateur porte le flag isAdmin (decision architecturale
|
||||
* gravee au ticket #343 section 11 : is_admin est le seul levier technique
|
||||
* de bypass, jamais remplace par un check de role).
|
||||
* - Sinon, compare l'attribut aux permissions effectives de l'utilisateur
|
||||
* (union dedupliquee triee venant des roles et des permissions directes).
|
||||
*
|
||||
* @extends Voter<string, mixed>
|
||||
*/
|
||||
final class PermissionVoter extends Voter
|
||||
{
|
||||
/**
|
||||
* Regex de reconnaissance des codes de permission.
|
||||
*
|
||||
* Contraintes :
|
||||
* - Premier caractere alphabetique minuscule (pas de chiffre, pas de ROLE_).
|
||||
* - Au moins un point de separation (ecarte les attributs atomiques
|
||||
* type ROLE_ADMIN ou IS_AUTHENTICATED_FULLY).
|
||||
* - Segments en snake_case minuscule coherents avec les permissions
|
||||
* declarees par les *Module::permissions() et validees par app:sync-permissions.
|
||||
*/
|
||||
private const string PERMISSION_CODE_PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return (bool) preg_match(self::PERMISSION_CODE_PATTERN, $attribute);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
// Token anonyme ou user d'un autre type : on refuse explicitement.
|
||||
// Les voters core (AuthenticatedVoter) se chargent deja du cas
|
||||
// "pas authentifie du tout".
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
// Bypass total : decision architecturale #343 section 11.
|
||||
// Cette regle est dupliquee cote front dans usePermissions()
|
||||
// et les deux doivent bouger ensemble si elle evolue un jour.
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($attribute, $user->getEffectivePermissions(), true);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<object>
|
||||
@@ -16,10 +17,10 @@ class SidebarProvider implements ProviderInterface
|
||||
/** @var list<string> */
|
||||
private readonly array $activeModuleIds;
|
||||
|
||||
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
|
||||
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string, permission?: string}>}> */
|
||||
private readonly array $sidebarConfig;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(private readonly Security $security)
|
||||
{
|
||||
$configDir = dirname(__DIR__, 5).'/config';
|
||||
|
||||
@@ -58,6 +59,18 @@ class SidebarProvider implements ProviderInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrage par permission RBAC : si l'item declare une permission
|
||||
// requise et que l'utilisateur courant ne la possede pas, l'item
|
||||
// est masque et sa route ajoutee aux routes desactivees.
|
||||
$requiredPermission = $item['permission'] ?? null;
|
||||
if (null !== $requiredPermission && !$this->security->isGranted($requiredPermission)) {
|
||||
if (isset($item['to'])) {
|
||||
$disabledRoutes[] = $item['to'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'label' => $item['label'],
|
||||
'to' => $item['to'],
|
||||
|
||||
Reference in New Issue
Block a user