feat: Attribution de rôles multiples par utilisateur

Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.

Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
This commit is contained in:
2026-02-10 07:57:43 +01:00
parent 9ccad77bf0
commit e930c505df
93 changed files with 2527 additions and 165 deletions

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignRole;
final readonly class AssignRoleCommand
{
public function __construct(
public string $userId,
public string $role,
public string $tenantId = '',
) {
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignRole;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignRoleHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(AssignRoleCommand $command): User
{
$userId = UserId::fromString($command->userId);
$user = $this->userRepository->get($userId);
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
throw UserNotFoundException::withId($userId);
}
$role = Role::tryFrom($command->role);
if ($role === null) {
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
}
$user->attribuerRole($role, $this->clock->now());
$this->userRepository->save($user);
return $user;
}
}

View File

@@ -4,8 +4,18 @@ declare(strict_types=1);
namespace App\Administration\Application\Command\InviteUser;
use InvalidArgumentException;
use function is_string;
final readonly class InviteUserCommand
{
/** @var string[] */
public array $roles;
/**
* @param string[] $roles
*/
public function __construct(
public string $tenantId,
public string $schoolName,
@@ -14,6 +24,16 @@ final readonly class InviteUserCommand
public string $firstName,
public string $lastName,
public ?string $dateNaissance = null,
array $roles = [],
) {
$resolved = $roles !== [] ? $roles : [$role];
foreach ($resolved as $r) {
if (!is_string($r)) {
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
}
}
$this->roles = $resolved;
}
}

View File

@@ -11,6 +11,10 @@ use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function array_slice;
use DateTimeImmutable;
use InvalidArgumentException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
@@ -26,16 +30,24 @@ final readonly class InviteUserHandler
/**
* @throws EmailDejaUtiliseeException if email is already used in this tenant
* @throws InvalidArgumentException if the role is invalid
* @throws InvalidArgumentException if a role is invalid
*/
public function __invoke(InviteUserCommand $command): User
{
$tenantId = TenantId::fromString($command->tenantId);
$email = new Email($command->email);
$role = Role::tryFrom($command->role);
if ($role === null) {
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
$roles = array_map(static function (string $r): Role {
$role = Role::tryFrom($r);
if ($role === null) {
throw new InvalidArgumentException("Rôle invalide : \"{$r}\".");
}
return $role;
}, $command->roles);
if ($roles === []) {
throw new InvalidArgumentException('Au moins un rôle est requis.');
}
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
@@ -43,19 +55,25 @@ final readonly class InviteUserHandler
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
}
$now = $this->clock->now();
$user = User::inviter(
email: $email,
role: $role,
role: $roles[0],
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $command->firstName,
lastName: $command->lastName,
invitedAt: $this->clock->now(),
invitedAt: $now,
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
);
foreach (array_slice($roles, 1) as $additionalRole) {
$user->attribuerRole($additionalRole, $now);
}
$this->userRepository->save($user);
return $user;

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveRole;
final readonly class RemoveRoleCommand
{
public function __construct(
public string $userId,
public string $role,
public string $tenantId = '',
) {
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\RemoveRole;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use InvalidArgumentException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class RemoveRoleHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
) {
}
public function __invoke(RemoveRoleCommand $command): User
{
$userId = UserId::fromString($command->userId);
$user = $this->userRepository->get($userId);
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
throw UserNotFoundException::withId($userId);
}
$role = Role::tryFrom($command->role);
if ($role === null) {
throw new InvalidArgumentException("Rôle invalide : \"{$command->role}\".");
}
$user->retirerRole($role, $this->clock->now());
$this->userRepository->save($user);
return $user;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateUserRoles;
use InvalidArgumentException;
use function is_string;
final readonly class UpdateUserRolesCommand
{
/**
* @param string[] $roles
*/
public function __construct(
public string $userId,
public array $roles,
public string $tenantId = '',
) {
foreach ($roles as $r) {
if (!is_string($r)) {
throw new InvalidArgumentException('Chaque rôle doit être une chaîne de caractères.');
}
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateUserRoles;
use App\Administration\Application\Port\ActiveRoleStore;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_values;
use function in_array;
use InvalidArgumentException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateUserRolesHandler
{
public function __construct(
private UserRepository $userRepository,
private Clock $clock,
private ActiveRoleStore $activeRoleStore,
) {
}
public function __invoke(UpdateUserRolesCommand $command): User
{
$userId = UserId::fromString($command->userId);
$user = $this->userRepository->get($userId);
if ($command->tenantId !== '' && !$user->tenantId->equals(TenantId::fromString($command->tenantId))) {
throw UserNotFoundException::withId($userId);
}
if ($command->roles === []) {
throw new InvalidArgumentException('Au moins un rôle est requis.');
}
$targetRoles = array_map(static function (string $r): Role {
$role = Role::tryFrom($r);
if ($role === null) {
throw new InvalidArgumentException("Rôle invalide : \"{$r}\".");
}
return $role;
}, $command->roles);
$now = $this->clock->now();
// Add new roles first to avoid "last role" exception
foreach ($targetRoles as $targetRole) {
if (!$user->aLeRole($targetRole)) {
$user->attribuerRole($targetRole, $now);
}
}
// Collect roles to remove, then remove them (avoid mutating during iteration)
/** @var list<Role> $rolesToRemove */
$rolesToRemove = array_values(array_filter(
$user->roles,
static fn (Role $currentRole) => !in_array($currentRole, $targetRoles, true),
));
foreach ($rolesToRemove as $roleToRemove) {
$user->retirerRole($roleToRemove, $now);
}
$this->userRepository->save($user);
// Clear cached active role to avoid stale reference to a removed role
$this->activeRoleStore->clear($user);
return $user;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
/**
* Port interface for storing the user's currently active role.
*
* When a user has multiple roles (FR5), they can switch between them.
* This store persists the selected role across requests.
*/
interface ActiveRoleStore
{
/**
* Stores the active role for the given user.
*/
public function store(User $user, Role $role): void;
/**
* Returns the stored active role for the given user, or null if none.
*/
public function get(User $user): ?Role;
/**
* Clears the stored active role for the given user (e.g. at logout).
*/
public function clear(User $user): void;
}

View File

@@ -35,7 +35,7 @@ final readonly class GetUsersHandler
if ($filterRole !== null) {
$users = array_filter(
$users,
static fn ($user) => $user->role === $filterRole,
static fn ($user) => $user->aLeRole($filterRole),
);
}
}

View File

@@ -4,17 +4,23 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
final readonly class UserDto
{
/**
* @param string[] $roles
*/
public function __construct(
public string $id,
public string $email,
public string $role,
public string $roleLabel,
/** @var string[] */
public array $roles,
public string $firstName,
public string $lastName,
public string $statut,
@@ -34,6 +40,7 @@ final readonly class UserDto
email: (string) $user->email,
role: $user->role->value,
roleLabel: $user->role->label(),
roles: array_map(static fn (Role $r) => $r->value, $user->roles),
firstName: $user->firstName,
lastName: $user->lastName,
statut: $user->statut->value,

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Application\Port\ActiveRoleStore;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use DomainException;
final class RoleContext
{
public function __construct(
private readonly ActiveRoleStore $activeRoleStore,
) {
}
public function switchTo(User $user, Role $role): void
{
if (!$user->peutSeConnecter()) {
throw new DomainException('Le compte n\'est pas actif.');
}
if (!$user->aLeRole($role)) {
throw RoleNonAttribueException::pour($user->id, $role);
}
$this->activeRoleStore->store($user, $role);
}
public function getActiveRole(User $user): Role
{
$stored = $this->activeRoleStore->get($user);
if ($stored !== null) {
return $stored;
}
return $user->rolePrincipal();
}
public function clear(User $user): void
{
$this->activeRoleStore->clear($user);
}
}