diff --git a/config/services.yaml b/config/services.yaml index 2b1c163..bf453f6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -69,4 +69,8 @@ services: App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository' + App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository' + + App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' + App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier' diff --git a/migrations/Version20260619145109.php b/migrations/Version20260619145109.php new file mode 100644 index 0000000..5de9930 --- /dev/null +++ b/migrations/Version20260619145109.php @@ -0,0 +1,74 @@ +addSql('CREATE TABLE permission (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(255) NOT NULL, label VARCHAR(255) NOT NULL, module VARCHAR(100) NOT NULL, orphan BOOLEAN NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_E04992AA77153098 ON permission (code)'); + $this->addSql('CREATE INDEX idx_permission_module ON permission (module)'); + $this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)'); + $this->addSql('COMMENT ON COLUMN permission.code IS \'Permission code (module.resource[.sub].action)\''); + $this->addSql('COMMENT ON COLUMN permission.label IS \'Human-readable permission label\''); + $this->addSql('COMMENT ON COLUMN permission.module IS \'Owning module id (e.g. core)\''); + $this->addSql('COMMENT ON COLUMN permission.orphan IS \'True when the permission is no longer declared by any active module\''); + + $this->addSql('CREATE TABLE "role" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, code VARCHAR(100) NOT NULL, label VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, is_system BOOLEAN NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_57698A6A77153098 ON "role" (code)'); + $this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)'); + $this->addSql('COMMENT ON COLUMN "role".code IS \'Immutable role code (snake_case)\''); + $this->addSql('COMMENT ON COLUMN "role".label IS \'Human-readable role label\''); + $this->addSql('COMMENT ON COLUMN "role".description IS \'Optional role description\''); + $this->addSql('COMMENT ON COLUMN "role".is_system IS \'True for built-in roles that cannot be deleted\''); + + $this->addSql('CREATE TABLE role_permission (role_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (role_id, permission_id))'); + $this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)'); + $this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)'); + + $this->addSql('CREATE TABLE user_role (user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id))'); + $this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)'); + $this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)'); + + $this->addSql('CREATE TABLE user_permission (user_id INT NOT NULL, permission_id INT NOT NULL, PRIMARY KEY (user_id, permission_id))'); + $this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)'); + $this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)'); + + $this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE role_permission ADD CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_role ADD CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_permission ADD CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC'); + $this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395'); + $this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395'); + $this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA'); + $this->addSql('DROP TABLE role_permission'); + $this->addSql('DROP TABLE user_role'); + $this->addSql('DROP TABLE user_permission'); + $this->addSql('DROP TABLE permission'); + $this->addSql('DROP TABLE "role"'); + } +} diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php new file mode 100644 index 0000000..5094499 --- /dev/null +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -0,0 +1,113 @@ + ['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, options: ['comment' => 'Permission code (module.resource[.sub].action)'])] + #[Groups(['permission:read', 'role:read'])] + private string $code; + + #[ORM\Column(length: 255, options: ['comment' => 'Human-readable permission label'])] + #[Groups(['permission:read', 'role:read'])] + private string $label; + + #[ORM\Column(length: 100, options: ['comment' => 'Owning module id (e.g. core)'])] + #[Groups(['permission:read', 'role:read'])] + private string $module; + + #[ORM\Column(options: ['comment' => 'True when the permission is no longer declared by any active module'])] + #[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; + } +} diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php new file mode 100644 index 0000000..8f15d01 --- /dev/null +++ b/src/Module/Core/Domain/Entity/Role.php @@ -0,0 +1,145 @@ + ['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, options: ['comment' => 'Immutable role code (snake_case)'])] + #[Groups(['role:read', 'role:write'])] + private string $code; + + #[ORM\Column(length: 255, options: ['comment' => 'Human-readable role label'])] + #[Groups(['role:read', 'role:write'])] + private string $label; + + #[ORM\Column(type: 'text', nullable: true, options: ['comment' => 'Optional role description'])] + #[Groups(['role:read', 'role:write'])] + private ?string $description; + + #[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])] + #[Groups(['role:read'])] + private bool $isSystem; + + /** + * @var Collection + */ + #[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 + */ + 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); + } + } +} diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 7af2ddc..87794ee 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -17,6 +17,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -135,9 +137,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU #[Groups(['me:read', 'user:list', 'user:write'])] private float $initialLeaveBalance = 0.0; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_role')] + #[Groups(['user:rbac:read'])] + private Collection $rbacRoles; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_permission')] + #[Groups(['user:rbac:read'])] + private Collection $directPermissions; + public function __construct() { - $this->createdAt = new DateTimeImmutable(); + $this->createdAt = new DateTimeImmutable(); + $this->rbacRoles = new ArrayCollection(); + $this->directPermissions = new ArrayCollection(); } public function getId(): ?int @@ -373,4 +393,67 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU return $this; } + + /** + * @return Collection + */ + 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 + */ + 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 + */ + #[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; + } } diff --git a/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php new file mode 100644 index 0000000..bf33752 --- /dev/null +++ b/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php @@ -0,0 +1,15 @@ + */ + public function findAll(): array; + + /** @return list */ + public function findAllCodes(): array; + + public function save(Permission $permission): void; +} diff --git a/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php new file mode 100644 index 0000000..adf8420 --- /dev/null +++ b/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php @@ -0,0 +1,19 @@ + */ + public function findAll(): array; + + public function save(Role $role): void; +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php new file mode 100644 index 0000000..f8b00ed --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php @@ -0,0 +1,45 @@ + + */ +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; + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php new file mode 100644 index 0000000..5ce888f --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php @@ -0,0 +1,51 @@ + + */ +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 */ + public function findAll(): array + { + return array_values($this->findBy([])); + } + + /** @return list */ + public function findAllCodes(): array + { + /** @var list $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); + } +} diff --git a/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php new file mode 100644 index 0000000..2853629 --- /dev/null +++ b/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php @@ -0,0 +1,42 @@ + + */ +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 */ + public function findAll(): array + { + return array_values($this->findBy([])); + } + + public function save(Role $role): void + { + $this->getEntityManager()->persist($role); + } +} diff --git a/src/Shared/Domain/Contract/UserInterface.php b/src/Shared/Domain/Contract/UserInterface.php index 8419b27..7a3e89a 100644 --- a/src/Shared/Domain/Contract/UserInterface.php +++ b/src/Shared/Domain/Contract/UserInterface.php @@ -26,4 +26,7 @@ interface UserInterface public function getAvatarUrl(): ?string; public function getIsEmployee(): bool; + + /** @return list */ + public function getEffectivePermissions(): array; } diff --git a/tests/Unit/Module/Core/Domain/Entity/PermissionTest.php b/tests/Unit/Module/Core/Domain/Entity/PermissionTest.php new file mode 100644 index 0000000..5dab1c4 --- /dev/null +++ b/tests/Unit/Module/Core/Domain/Entity/PermissionTest.php @@ -0,0 +1,58 @@ +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()); + } +} diff --git a/tests/Unit/Module/Core/Domain/Entity/RoleTest.php b/tests/Unit/Module/Core/Domain/Entity/RoleTest.php new file mode 100644 index 0000000..13ef824 --- /dev/null +++ b/tests/Unit/Module/Core/Domain/Entity/RoleTest.php @@ -0,0 +1,58 @@ +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()); + } +} diff --git a/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php b/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php index bac0a42..750e9da 100644 --- a/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php +++ b/tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php @@ -115,6 +115,11 @@ final class TimestampableBlamableSubscriberTest extends TestCase { return false; } + + public function getEffectivePermissions(): array + { + return []; + } }; }