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

@@ -1,4 +1,14 @@
security:
# Role hierarchy — Direction inherits read permissions on whole school (AC3, FR5)
role_hierarchy:
ROLE_SUPER_ADMIN: [ROLE_ADMIN]
ROLE_ADMIN: [ROLE_PROF, ROLE_VIE_SCOLAIRE, ROLE_SECRETARIAT]
ROLE_PROF: [ROLE_USER]
ROLE_VIE_SCOLAIRE: [ROLE_USER]
ROLE_SECRETARIAT: [ROLE_USER]
ROLE_PARENT: [ROLE_USER]
ROLE_ELEVE: [ROLE_USER]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

View File

@@ -151,6 +151,10 @@ services:
App\Administration\Application\Port\GradeExistenceChecker:
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
# ActiveRoleStore (session-scoped cache for active role switching)
App\Administration\Application\Port\ActiveRoleStore:
alias: App\Administration\Infrastructure\Service\CacheActiveRoleStore
# GeoLocation Service (null implementation - no geolocation)
App\Administration\Application\Port\GeoLocationService:
alias: App\Administration\Infrastructure\Service\NullGeoLocationService

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);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RoleAttribue implements DomainEvent
{
public function __construct(
public UserId $userId,
public string $email,
public string $role,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->userId->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class RoleRetire implements DomainEvent
{
public function __construct(
public UserId $userId,
public string $email,
public string $role,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->userId->value;
}
}

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class ActivationTokenAlreadyUsedException extends RuntimeException
final class ActivationTokenAlreadyUsedException extends DomainException
{
public static function forToken(ActivationTokenId $tokenId): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class ActivationTokenExpiredException extends RuntimeException
final class ActivationTokenExpiredException extends DomainException
{
public static function forToken(ActivationTokenId $tokenId): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class ActivationTokenNotFoundException extends RuntimeException
final class ActivationTokenNotFoundException extends DomainException
{
public static function withId(ActivationTokenId $tokenId): self
{

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
final class CannotChangeGradingModeWithExistingGradesException extends RuntimeException
final class CannotChangeGradingModeWithExistingGradesException extends DomainException
{
public function __construct()
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class ClassNameInvalideException extends RuntimeException
final class ClassNameInvalideException extends DomainException
{
public static function pourLongueur(string $value, int $min, int $max): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use RuntimeException;
use DomainException;
use function sprintf;
final class ClasseDejaExistanteException extends RuntimeException
final class ClasseDejaExistanteException extends DomainException
{
public static function avecNom(ClassName $name): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use RuntimeException;
use DomainException;
use function sprintf;
final class ClasseNonSupprimableException extends RuntimeException
final class ClasseNonSupprimableException extends DomainException
{
public static function carElevesAffectes(ClassId $classId, int $nombreEleves): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use RuntimeException;
use DomainException;
use function sprintf;
final class ClasseNotFoundException extends RuntimeException
final class ClasseNotFoundException extends DomainException
{
public static function withId(ClassId $classId): self
{

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use DomainException;
use function sprintf;
final class CompteNonActivableException extends RuntimeException
final class CompteNonActivableException extends DomainException
{
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
{

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class DernierRoleNonRetirableException extends DomainException
{
public static function pour(UserId $userId): self
{
return new self(sprintf(
'Impossible de retirer le dernier rôle de l\'utilisateur %s.',
$userId,
));
}
}

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\Email;
use App\Shared\Domain\Tenant\TenantId;
use RuntimeException;
use DomainException;
use function sprintf;
final class EmailDejaUtiliseeException extends RuntimeException
final class EmailDejaUtiliseeException extends DomainException
{
public static function dansTenant(Email $email, TenantId $tenantId): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class EmailInvalideException extends RuntimeException
final class EmailInvalideException extends DomainException
{
public static function pourAdresse(string $email): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\GradingConfiguration\SchoolGradingConfigurationId;
use RuntimeException;
use DomainException;
use function sprintf;
final class GradingConfigurationNotFoundException extends RuntimeException
final class GradingConfigurationNotFoundException extends DomainException
{
public static function withId(SchoolGradingConfigurationId $id): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class InvalidPeriodCountException extends RuntimeException
final class InvalidPeriodCountException extends DomainException
{
public static function forType(string $type, int $expected, int $actual): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class InvalidPeriodDatesException extends RuntimeException
final class InvalidPeriodDatesException extends DomainException
{
public static function endBeforeStart(string $label, string $start, string $end): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class PasswordResetTokenAlreadyUsedException extends RuntimeException
final class PasswordResetTokenAlreadyUsedException extends DomainException
{
public static function forToken(PasswordResetTokenId $tokenId): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class PasswordResetTokenExpiredException extends RuntimeException
final class PasswordResetTokenExpiredException extends DomainException
{
public static function forToken(PasswordResetTokenId $tokenId): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\PasswordResetToken\PasswordResetTokenId;
use RuntimeException;
use DomainException;
use function sprintf;
final class PasswordResetTokenNotFoundException extends RuntimeException
final class PasswordResetTokenNotFoundException extends DomainException
{
public static function withId(PasswordResetTokenId $tokenId): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class PeriodeAvecNotesException extends RuntimeException
final class PeriodeAvecNotesException extends DomainException
{
public static function confirmationRequise(string $label): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class PeriodeNonTrouveeException extends RuntimeException
final class PeriodeNonTrouveeException extends DomainException
{
public static function pourSequence(int $sequence, string $academicYearId): self
{

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
final class PeriodesDejaConfigureesException extends RuntimeException
final class PeriodesDejaConfigureesException extends DomainException
{
public static function pourAnnee(string $academicYearId): self
{

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
final class PeriodesNonConfigureesException extends RuntimeException
final class PeriodesNonConfigureesException extends DomainException
{
public static function pourAnnee(string $academicYearId): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class PeriodsCoverageGapException extends RuntimeException
final class PeriodsCoverageGapException extends DomainException
{
public static function gapBetween(string $periodA, string $periodB): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class PeriodsOverlapException extends RuntimeException
final class PeriodsOverlapException extends DomainException
{
public static function between(string $periodA, string $periodB): self
{

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class RoleDejaAttribueException extends DomainException
{
public static function pour(UserId $userId, Role $role): self
{
return new self(sprintf(
'Le rôle « %s » est déjà attribué à l\'utilisateur %s.',
$role->label(),
$userId,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class RoleNonAttribueException extends DomainException
{
public static function pour(UserId $userId, Role $role): self
{
return new self(sprintf(
'Le rôle « %s » n\'est pas attribué à l\'utilisateur %s.',
$role->label(),
$userId,
));
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use RuntimeException;
use DomainException;
use function sprintf;
@@ -14,7 +14,7 @@ use function sprintf;
*
* @see Story 1.6 - Gestion des sessions
*/
final class SessionNotFoundException extends RuntimeException
final class SessionNotFoundException extends DomainException
{
public function __construct(TokenFamilyId $familyId)
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectCodeInvalideException extends RuntimeException
final class SubjectCodeInvalideException extends DomainException
{
public static function pourFormat(string $value, int $min, int $max): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectColorInvalideException extends RuntimeException
final class SubjectColorInvalideException extends DomainException
{
public static function pourFormat(string $value): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectCode;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectDejaExistanteException extends RuntimeException
final class SubjectDejaExistanteException extends DomainException
{
public static function avecCode(SubjectCode $code): self
{

View File

@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectNameInvalideException extends RuntimeException
final class SubjectNameInvalideException extends DomainException
{
public static function pourLongueur(string $value, int $min, int $max): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectId;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectNonSupprimableException extends RuntimeException
final class SubjectNonSupprimableException extends DomainException
{
public static function avecNotes(SubjectId $id): self
{

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Subject\SubjectId;
use RuntimeException;
use DomainException;
use function sprintf;
final class SubjectNotFoundException extends RuntimeException
final class SubjectNotFoundException extends DomainException
{
public static function withId(SubjectId $id): self
{

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
/**
* Exception thrown when a refresh token has already been rotated but is still in grace period.
@@ -14,7 +14,7 @@ use RuntimeException;
*
* @see Story 1.4 - Connexion utilisateur
*/
final class TokenAlreadyRotatedException extends RuntimeException
final class TokenAlreadyRotatedException extends DomainException
{
public function __construct()
{

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use RuntimeException;
use DomainException;
use function sprintf;
@@ -14,7 +14,7 @@ use function sprintf;
* This indicates a concurrent request is processing the same token,
* and the client should retry after a short delay.
*/
final class TokenConsumptionInProgressException extends RuntimeException
final class TokenConsumptionInProgressException extends DomainException
{
public function __construct(string $tokenValue)
{

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
use RuntimeException;
use DomainException;
use function sprintf;
@@ -21,7 +21,7 @@ use function sprintf;
* - Un audit log doit être créé
* - Une alerte de sécurité peut être envoyée
*/
final class TokenReplayDetectedException extends RuntimeException
final class TokenReplayDetectedException extends DomainException
{
public function __construct(
public readonly TokenFamilyId $familyId,

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use DomainException;
use function sprintf;
final class UserNotFoundException extends RuntimeException
final class UserNotFoundException extends DomainException
{
public static function withId(UserId $userId): self
{

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use DomainException;
use function sprintf;
final class UtilisateurDejaInviteException extends RuntimeException
final class UtilisateurDejaInviteException extends DomainException
{
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
{

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use DomainException;
use function sprintf;
final class UtilisateurNonBlocableException extends RuntimeException
final class UtilisateurNonBlocableException extends DomainException
{
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
{

View File

@@ -6,11 +6,11 @@ namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\UserId;
use RuntimeException;
use DomainException;
use function sprintf;
final class UtilisateurNonDeblocableException extends RuntimeException
final class UtilisateurNonDeblocableException extends DomainException
{
public static function carStatutIncompatible(UserId $userId, StatutCompte $statut): self
{

View File

@@ -8,10 +8,15 @@ use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\MotDePasseChange;
use App\Administration\Domain\Event\RoleAttribue;
use App\Administration\Domain\Event\RoleRetire;
use App\Administration\Domain\Event\UtilisateurBloque;
use App\Administration\Domain\Event\UtilisateurDebloque;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Domain\Exception\CompteNonActivableException;
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
use App\Administration\Domain\Exception\RoleDejaAttribueException;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Exception\UtilisateurDejaInviteException;
use App\Administration\Domain\Exception\UtilisateurNonBlocableException;
use App\Administration\Domain\Exception\UtilisateurNonDeblocableException;
@@ -19,12 +24,20 @@ use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function array_values;
use function count;
use DateTimeImmutable;
use function in_array;
use InvalidArgumentException;
/**
* Aggregate Root representing a user in Classeo.
*
* A user belongs to a school (tenant) and has a role.
* A user belongs to a school (tenant) and can have multiple roles (FR5).
* The account lifecycle goes through multiple statuses: creation → activation.
* Minors (< 15 years) require parental consent before activation.
*/
@@ -37,10 +50,16 @@ final class User extends AggregateRoot
public private(set) ?DateTimeImmutable $blockedAt = null;
public private(set) ?string $blockedReason = null;
/** @var Role[] */
public private(set) array $roles;
/**
* @param Role[] $roles
*/
private function __construct(
public private(set) UserId $id,
public private(set) Email $email,
public private(set) Role $role,
array $roles,
public private(set) TenantId $tenantId,
public private(set) string $schoolName,
public private(set) StatutCompte $statut,
@@ -49,6 +68,14 @@ final class User extends AggregateRoot
public private(set) string $firstName = '',
public private(set) string $lastName = '',
) {
$this->roles = $roles;
}
/**
* Returns the primary role (first assigned role) for backward compatibility.
*/
public Role $role {
get => $this->roles[0];
}
/**
@@ -65,7 +92,7 @@ final class User extends AggregateRoot
$user = new self(
id: UserId::generate(),
email: $email,
role: $role,
roles: [$role],
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::EN_ATTENTE,
@@ -84,6 +111,73 @@ final class User extends AggregateRoot
return $user;
}
/**
* Assigns an additional role to the user.
*
* @throws RoleDejaAttribueException if the role is already assigned
*/
public function attribuerRole(Role $role, DateTimeImmutable $at): void
{
if ($this->aLeRole($role)) {
throw RoleDejaAttribueException::pour($this->id, $role);
}
$this->roles = [...$this->roles, $role];
$this->recordEvent(new RoleAttribue(
userId: $this->id,
email: (string) $this->email,
role: $role->value,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Removes a role from the user.
*
* @throws RoleNonAttribueException if the role is not assigned
* @throws DernierRoleNonRetirableException if this is the last role
*/
public function retirerRole(Role $role, DateTimeImmutable $at): void
{
if (!$this->aLeRole($role)) {
throw RoleNonAttribueException::pour($this->id, $role);
}
if (count($this->roles) === 1) {
throw DernierRoleNonRetirableException::pour($this->id);
}
$this->roles = array_values(
array_filter($this->roles, static fn (Role $r) => $r !== $role),
);
$this->recordEvent(new RoleRetire(
userId: $this->id,
email: (string) $this->email,
role: $role->value,
tenantId: $this->tenantId,
occurredOn: $at,
));
}
/**
* Checks if the user has a specific role.
*/
public function aLeRole(Role $role): bool
{
return in_array($role, $this->roles, true);
}
/**
* Returns the primary role (first assigned).
*/
public function rolePrincipal(): Role
{
return $this->roles[0];
}
/**
* Activates the account with the hashed password.
*
@@ -167,7 +261,7 @@ final class User extends AggregateRoot
$user = new self(
id: UserId::generate(),
email: $email,
role: $role,
roles: [$role],
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::EN_ATTENTE,
@@ -296,12 +390,14 @@ final class User extends AggregateRoot
/**
* Reconstitutes a User from storage.
*
* @param Role[] $roles
*
* @internal For Infrastructure use only
*/
public static function reconstitute(
UserId $id,
Email $email,
Role $role,
array $roles,
TenantId $tenantId,
string $schoolName,
StatutCompte $statut,
@@ -316,10 +412,14 @@ final class User extends AggregateRoot
?DateTimeImmutable $blockedAt = null,
?string $blockedReason = null,
): self {
if ($roles === []) {
throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.');
}
$user = new self(
id: $id,
email: $email,
role: $role,
roles: $roles,
tenantId: $tenantId,
schoolName: $schoolName,
statut: $statut,

View File

@@ -98,6 +98,17 @@ final readonly class LogoutController
->withSameSite($isSecure ? 'strict' : 'lax'),
);
// Clear session ID cookie (active role scoping)
$response->headers->setCookie(
Cookie::create('classeo_sid')
->withValue('')
->withExpires(new DateTimeImmutable('-1 hour'))
->withPath('/api')
->withHttpOnly(true)
->withSecure($isSecure)
->withSameSite($isSecure ? 'strict' : 'lax'),
);
return $response;
}
}

View File

@@ -10,12 +10,18 @@ use App\Administration\Application\Command\InviteUser\InviteUserCommand;
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Exception\EmailInvalideException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\UserResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\UserVoter;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use InvalidArgumentException;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -34,6 +40,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private Clock $clock,
private Security $security,
) {
}
@@ -54,6 +61,19 @@ final readonly class InviteUserProcessor implements ProcessorInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$tenantConfig = $this->tenantContext->getCurrentTenantConfig();
// Guard: prevent privilege escalation (only SUPER_ADMIN can assign SUPER_ADMIN)
/** @var string[] $requestedRoles */
$requestedRoles = $data->roles ?? [];
$currentUser = $this->security->getUser();
if ($currentUser instanceof SecurityUser) {
$currentRoles = $currentUser->getRoles();
if (!in_array(Role::SUPER_ADMIN->value, $currentRoles, true)
&& in_array(Role::SUPER_ADMIN->value, $requestedRoles, true)) {
throw new AccessDeniedHttpException('Seul un super administrateur peut attribuer le rôle SUPER_ADMIN.');
}
}
try {
$command = new InviteUserCommand(
tenantId: $tenantId,
@@ -62,6 +82,7 @@ final readonly class InviteUserProcessor implements ProcessorInterface
role: $data->role ?? '',
firstName: $data->firstName ?? '',
lastName: $data->lastName ?? '',
roles: $data->roles ?? [],
);
$user = ($this->handler)($command);

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Service\RoleContext;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\SwitchRoleInput;
use App\Administration\Infrastructure\Api\Resource\SwitchRoleOutput;
use App\Administration\Infrastructure\Security\SecurityUser;
use DomainException;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProcessorInterface<SwitchRoleInput, SwitchRoleOutput>
*/
final readonly class SwitchRoleProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private UserRepository $userRepository,
private RoleContext $roleContext,
) {
}
/**
* @param SwitchRoleInput $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchRoleOutput
{
$currentUser = $this->security->getUser();
if (!$currentUser instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
$role = Role::tryFrom($data->role ?? '');
if ($role === null) {
throw new BadRequestHttpException('Rôle invalide.');
}
try {
$user = $this->userRepository->get(UserId::fromString($currentUser->userId()));
$this->roleContext->switchTo($user, $role);
} catch (UserNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (RoleNonAttribueException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage());
}
return new SwitchRoleOutput(
activeRole: $role->value,
activeRoleLabel: $role->label(),
);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesCommand;
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesHandler;
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
use App\Administration\Domain\Exception\RoleDejaAttribueException;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Api\Resource\UserResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Administration\Infrastructure\Security\UserVoter;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use InvalidArgumentException;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* @implements ProcessorInterface<UserResource, UserResource>
*/
final readonly class UpdateUserRolesProcessor implements ProcessorInterface
{
public function __construct(
private UpdateUserRolesHandler $handler,
private MessageBusInterface $eventBus,
private AuthorizationCheckerInterface $authorizationChecker,
private TenantContext $tenantContext,
private Clock $clock,
private Security $security,
) {
}
/**
* @param UserResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UserResource
{
if (!$this->authorizationChecker->isGranted(UserVoter::MANAGE_ROLES)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à modifier les rôles.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
/** @var string $userId */
$userId = $uriVariables['id'] ?? '';
/** @var string[] $roles */
$roles = $data->roles ?? [];
// Guard: prevent privilege escalation (only SUPER_ADMIN can assign SUPER_ADMIN)
$currentUser = $this->security->getUser();
if ($currentUser instanceof SecurityUser) {
$currentRoles = $currentUser->getRoles();
if (!in_array(Role::SUPER_ADMIN->value, $currentRoles, true)
&& in_array(Role::SUPER_ADMIN->value, $roles, true)) {
throw new AccessDeniedHttpException('Seul un super administrateur peut attribuer le rôle SUPER_ADMIN.');
}
// Guard: prevent admin self-demotion
if ($currentUser->userId() === $userId) {
$isCurrentlyAdmin = in_array(Role::ADMIN->value, $currentRoles, true)
|| in_array(Role::SUPER_ADMIN->value, $currentRoles, true);
$willRemainAdmin = in_array(Role::ADMIN->value, $roles, true)
|| in_array(Role::SUPER_ADMIN->value, $roles, true);
if ($isCurrentlyAdmin && !$willRemainAdmin) {
throw new BadRequestHttpException('Vous ne pouvez pas retirer votre propre rôle administrateur.');
}
}
}
try {
$command = new UpdateUserRolesCommand(
userId: $userId,
roles: $roles,
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
);
$user = ($this->handler)($command);
// Domain events are collected by the aggregate during handler execution,
// then pulled and dispatched here. The handler does not dispatch events —
// this is the single dispatch point, not a double dispatch.
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return UserResource::fromDomain($user, $this->clock->now());
} catch (UserNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (InvalidArgumentException|RoleDejaAttribueException|RoleNonAttribueException|DernierRoleNonRetirableException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Service\RoleContext;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Api\Resource\MyRolesOutput;
use App\Administration\Infrastructure\Security\SecurityUser;
use function array_map;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<MyRolesOutput>
*/
final readonly class MyRolesProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private UserRepository $userRepository,
private RoleContext $roleContext,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MyRolesOutput
{
$currentUser = $this->security->getUser();
if (!$currentUser instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
try {
$user = $this->userRepository->get(UserId::fromString($currentUser->userId()));
} catch (UserNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
$activeRole = $this->roleContext->getActiveRole($user);
$output = new MyRolesOutput();
$output->roles = array_map(
static fn (Role $role) => ['value' => $role->value, 'label' => $role->label()],
$user->roles,
);
$output->activeRole = $activeRole->value;
$output->activeRoleLabel = $activeRole->label();
return $output;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Administration\Infrastructure\Api\Provider\MyRolesProvider;
#[ApiResource(
shortName: 'MyRoles',
operations: [
new Get(
uriTemplate: '/me/roles',
provider: MyRolesProvider::class,
name: 'get_my_roles',
),
],
)]
final class MyRolesOutput
{
#[ApiProperty(identifier: true, readable: false)]
public string $id = 'me';
/** @var array<array{value: string, label: string}> */
public array $roles = [];
public string $activeRole = '';
public string $activeRoleLabel = '';
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Administration\Infrastructure\Api\Processor\SwitchRoleProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'SwitchRole',
operations: [
new Post(
uriTemplate: '/me/switch-role',
processor: SwitchRoleProcessor::class,
output: SwitchRoleOutput::class,
name: 'switch_role',
),
],
)]
final class SwitchRoleInput
{
#[Assert\NotBlank(message: 'Le rôle est requis.')]
public ?string $role = null;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
final readonly class SwitchRoleOutput
{
public function __construct(
public string $activeRole,
public string $activeRoleLabel,
) {
}
}

View File

@@ -9,12 +9,15 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Administration\Application\Query\GetUsers\UserDto;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Api\Processor\BlockUserProcessor;
use App\Administration\Infrastructure\Api\Processor\InviteUserProcessor;
use App\Administration\Infrastructure\Api\Processor\ResendInvitationProcessor;
use App\Administration\Infrastructure\Api\Processor\UnblockUserProcessor;
use App\Administration\Infrastructure\Api\Processor\UpdateUserRolesProcessor;
use App\Administration\Infrastructure\Api\Provider\UserCollectionProvider;
use App\Administration\Infrastructure\Api\Provider\UserItemProvider;
use DateTimeImmutable;
@@ -24,6 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* API Resource pour la gestion des utilisateurs.
*
* @see Story 2.5 - Création Comptes Utilisateurs
* @see Story 2.6 - Attribution des Rôles
*/
#[ApiResource(
shortName: 'User',
@@ -60,6 +64,13 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: UnblockUserProcessor::class,
name: 'unblock_user',
),
new Put(
uriTemplate: '/users/{id}/roles',
read: false,
processor: UpdateUserRolesProcessor::class,
validationContext: ['groups' => ['Default', 'roles']],
name: 'update_user_roles',
),
],
)]
final class UserResource
@@ -76,6 +87,11 @@ final class UserResource
public ?string $roleLabel = null;
/** @var string[]|null */
#[Assert\NotBlank(message: 'Les rôles sont requis.', groups: ['roles'])]
#[Assert\Count(min: 1, minMessage: 'Au moins un rôle est requis.', groups: ['roles'])]
public ?array $roles = null;
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])]
public ?string $firstName = null;
@@ -107,6 +123,10 @@ final class UserResource
$resource->email = (string) $user->email;
$resource->role = $user->role->value;
$resource->roleLabel = $user->role->label();
$resource->roles = array_map(
static fn (Role $r) => $r->value,
$user->roles,
);
$resource->firstName = $user->firstName;
$resource->lastName = $user->lastName;
$resource->statut = $user->statut->value;
@@ -127,6 +147,7 @@ final class UserResource
$resource->email = $dto->email;
$resource->role = $dto->role;
$resource->roleLabel = $dto->roleLabel;
$resource->roles = $dto->roles;
$resource->firstName = $dto->firstName;
$resource->lastName = $dto->lastName;
$resource->statut = $dto->statut;

View File

@@ -131,7 +131,7 @@ final class CreateTestUserCommand extends Command
$user = User::reconstitute(
id: UserId::generate(),
email: new Email($email),
role: $role,
roles: [$role],
tenantId: $tenantId,
schoolName: $schoolName,
statut: StatutCompte::ACTIF,

View File

@@ -18,6 +18,9 @@ use DateTimeImmutable;
use function in_array;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;
use function sprintf;
/**
* Cache-based UserRepository for development and testing.
@@ -71,7 +74,7 @@ final readonly class CacheUserRepository implements UserRepository
return null;
}
/** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
/** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
$data = $item->get();
return $this->deserialize($data);
@@ -136,7 +139,7 @@ final readonly class CacheUserRepository implements UserRepository
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'role' => $user->role->value,
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
'statut' => $user->statut->value,
@@ -162,7 +165,8 @@ final readonly class CacheUserRepository implements UserRepository
* @param array{
* id: string,
* email: string,
* role: string,
* roles?: string[],
* role?: string,
* tenant_id: string,
* school_name: string,
* statut: string,
@@ -194,10 +198,19 @@ final readonly class CacheUserRepository implements UserRepository
$invitedAt = ($data['invited_at'] ?? null) !== null ? new DateTimeImmutable($data['invited_at']) : null;
$blockedAt = ($data['blocked_at'] ?? null) !== null ? new DateTimeImmutable($data['blocked_at']) : null;
// Support both legacy single role and new multi-role format
$roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []);
if ($roleStrings === []) {
throw new RuntimeException(sprintf('User %s has no roles in cache data.', $data['id']));
}
$roles = array_map(static fn (string $r) => Role::from($r), $roleStrings);
return User::reconstitute(
id: UserId::fromString($data['id']),
email: new Email($data['email']),
role: Role::from($data['role']),
roles: $roles,
tenantId: TenantId::fromString($data['tenant_id']),
schoolName: $data['school_name'],
statut: StatutCompte::from($data['statut']),

View File

@@ -16,6 +16,7 @@ use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -101,6 +102,16 @@ final readonly class LoginSuccessHandler
$response->headers->setCookie($cookie);
// Session ID cookie for active role scoping (per-device isolation)
$sessionIdCookie = Cookie::create('classeo_sid')
->withValue(Uuid::uuid4()->toString())
->withExpires($refreshToken->expiresAt)
->withPath('/api')
->withSecure($isSecure)
->withHttpOnly(true)
->withSameSite($isSecure ? 'strict' : 'lax');
$response->headers->setCookie($sessionIdCookie);
// Reset the rate limiter for this email
$this->rateLimiter->reset($email);

View File

@@ -23,12 +23,10 @@ final readonly class SecurityUserFactory
email: (string) $domainUser->email,
hashedPassword: $domainUser->hashedPassword ?? '',
tenantId: $domainUser->tenantId,
roles: [$this->mapRoleToSymfony($domainUser->role)],
roles: array_values(array_map(
static fn (Role $role) => $role->value,
$domainUser->roles,
)),
);
}
private function mapRoleToSymfony(Role $role): string
{
return $role->value;
}
}

View File

@@ -30,6 +30,7 @@ final class UserVoter extends Voter
public const string BLOCK = 'USER_BLOCK';
public const string UNBLOCK = 'USER_UNBLOCK';
public const string RESEND_INVITATION = 'USER_RESEND_INVITATION';
public const string MANAGE_ROLES = 'USER_MANAGE_ROLES';
private const array SUPPORTED_ATTRIBUTES = [
self::VIEW,
@@ -37,6 +38,7 @@ final class UserVoter extends Voter
self::BLOCK,
self::UNBLOCK,
self::RESEND_INVITATION,
self::MANAGE_ROLES,
];
#[Override]
@@ -66,7 +68,7 @@ final class UserVoter extends Voter
return match ($attribute) {
self::VIEW => $this->canView($roles),
self::CREATE, self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION => $this->canManage($roles),
self::CREATE, self::BLOCK, self::UNBLOCK, self::RESEND_INVITATION, self::MANAGE_ROLES => $this->canManage($roles),
default => false,
};
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Service;
use App\Administration\Application\Port\ActiveRoleStore;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use function is_string;
use Psr\Cache\CacheItemPoolInterface;
use function sprintf;
use Symfony\Component\HttpFoundation\RequestStack;
final readonly class CacheActiveRoleStore implements ActiveRoleStore
{
private const string CACHE_KEY_PREFIX = 'active_role_';
private const int TTL_SECONDS = 604_800; // 7 jours
public function __construct(
private CacheItemPoolInterface $sessionsCache,
private RequestStack $requestStack,
) {
}
public function store(User $user, Role $role): void
{
$item = $this->sessionsCache->getItem($this->cacheKey($user));
$item->set($role->value);
$item->expiresAfter(self::TTL_SECONDS);
$this->sessionsCache->save($item);
}
public function get(User $user): ?Role
{
$item = $this->sessionsCache->getItem($this->cacheKey($user));
if (!$item->isHit()) {
return null;
}
$stored = $item->get();
if (!is_string($stored)) {
return null;
}
$role = Role::tryFrom($stored);
if ($role !== null && $user->aLeRole($role)) {
return $role;
}
return null;
}
public function clear(User $user): void
{
$this->sessionsCache->deleteItem($this->cacheKey($user));
}
private function cacheKey(User $user): string
{
$sessionId = $this->requestStack->getCurrentRequest()?->cookies->get('classeo_sid', '') ?? '';
return sprintf('%s%s:%s', self::CACHE_KEY_PREFIX, $user->id, $sessionId);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command;
use App\Administration\Application\Command\AssignRole\AssignRoleCommand;
use App\Administration\Application\Command\AssignRole\AssignRoleHandler;
use App\Administration\Domain\Event\RoleAttribue;
use App\Administration\Domain\Exception\RoleDejaAttribueException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AssignRoleHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryUserRepository $userRepository;
private AssignRoleHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-08 10:00:00');
}
};
$this->handler = new AssignRoleHandler($this->userRepository, $clock);
}
#[Test]
public function itAssignsRoleToUser(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$result = ($this->handler)(new AssignRoleCommand(
userId: (string) $user->id,
role: Role::PARENT->value,
));
self::assertTrue($result->aLeRole(Role::PROF));
self::assertTrue($result->aLeRole(Role::PARENT));
}
#[Test]
public function itRecordsRoleAttribueEvent(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$user->pullDomainEvents();
($this->handler)(new AssignRoleCommand(
userId: (string) $user->id,
role: Role::PARENT->value,
));
$savedUser = $this->userRepository->get($user->id);
$events = $savedUser->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RoleAttribue::class, $events[0]);
}
#[Test]
public function itThrowsWhenRoleAlreadyAssigned(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(RoleDejaAttribueException::class);
($this->handler)(new AssignRoleCommand(
userId: (string) $user->id,
role: Role::PROF->value,
));
}
#[Test]
public function itThrowsWhenRoleIsInvalid(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(InvalidArgumentException::class);
($this->handler)(new AssignRoleCommand(
userId: (string) $user->id,
role: 'ROLE_INVALID',
));
}
private function createUser(Role $role): User
{
return User::creer(
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command;
use App\Administration\Application\Command\RemoveRole\RemoveRoleCommand;
use App\Administration\Application\Command\RemoveRole\RemoveRoleHandler;
use App\Administration\Domain\Event\RoleRetire;
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RemoveRoleHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryUserRepository $userRepository;
private RemoveRoleHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-08 10:00:00');
}
};
$this->handler = new RemoveRoleHandler($this->userRepository, $clock);
}
#[Test]
public function itRemovesRoleFromUser(): void
{
$user = $this->createUserWithRoles(Role::PROF, Role::PARENT);
$this->userRepository->save($user);
$result = ($this->handler)(new RemoveRoleCommand(
userId: (string) $user->id,
role: Role::PARENT->value,
));
self::assertTrue($result->aLeRole(Role::PROF));
self::assertFalse($result->aLeRole(Role::PARENT));
}
#[Test]
public function itRecordsRoleRetireEvent(): void
{
$user = $this->createUserWithRoles(Role::PROF, Role::PARENT);
$this->userRepository->save($user);
$user->pullDomainEvents();
($this->handler)(new RemoveRoleCommand(
userId: (string) $user->id,
role: Role::PARENT->value,
));
$savedUser = $this->userRepository->get($user->id);
$events = $savedUser->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RoleRetire::class, $events[0]);
}
#[Test]
public function itThrowsWhenRoleNotAssigned(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(RoleNonAttribueException::class);
($this->handler)(new RemoveRoleCommand(
userId: (string) $user->id,
role: Role::PARENT->value,
));
}
#[Test]
public function itThrowsWhenRemovingLastRole(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(DernierRoleNonRetirableException::class);
($this->handler)(new RemoveRoleCommand(
userId: (string) $user->id,
role: Role::PROF->value,
));
}
private function createUser(Role $role): User
{
return User::creer(
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
private function createUserWithRoles(Role $role, Role ...$additionalRoles): User
{
$user = $this->createUser($role);
foreach ($additionalRoles as $r) {
$user->attribuerRole($r, new DateTimeImmutable());
}
return $user;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command;
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesCommand;
use App\Administration\Application\Command\UpdateUserRoles\UpdateUserRolesHandler;
use App\Administration\Application\Port\ActiveRoleStore;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateUserRolesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryUserRepository $userRepository;
private UpdateUserRolesHandler $handler;
private ActiveRoleStore $activeRoleStore;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-08 10:00:00');
}
};
$this->activeRoleStore = $this->createMock(ActiveRoleStore::class);
$this->handler = new UpdateUserRolesHandler($this->userRepository, $clock, $this->activeRoleStore);
}
#[Test]
public function itUpdatesRolesBulk(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$result = ($this->handler)(new UpdateUserRolesCommand(
userId: (string) $user->id,
roles: [Role::PARENT->value, Role::VIE_SCOLAIRE->value],
));
self::assertFalse($result->aLeRole(Role::PROF));
self::assertTrue($result->aLeRole(Role::PARENT));
self::assertTrue($result->aLeRole(Role::VIE_SCOLAIRE));
}
#[Test]
public function itKeepsExistingRolesIfInTargetList(): void
{
$user = $this->createUser(Role::PROF);
$user->attribuerRole(Role::PARENT, new DateTimeImmutable());
$this->userRepository->save($user);
$result = ($this->handler)(new UpdateUserRolesCommand(
userId: (string) $user->id,
roles: [Role::PROF->value, Role::PARENT->value, Role::ADMIN->value],
));
self::assertTrue($result->aLeRole(Role::PROF));
self::assertTrue($result->aLeRole(Role::PARENT));
self::assertTrue($result->aLeRole(Role::ADMIN));
self::assertCount(3, $result->roles);
}
#[Test]
public function itThrowsWhenEmptyRoles(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Au moins un rôle est requis.');
($this->handler)(new UpdateUserRolesCommand(
userId: (string) $user->id,
roles: [],
));
}
#[Test]
public function itThrowsWhenInvalidRole(): void
{
$user = $this->createUser(Role::PROF);
$this->userRepository->save($user);
$this->expectException(InvalidArgumentException::class);
($this->handler)(new UpdateUserRolesCommand(
userId: (string) $user->id,
roles: ['ROLE_INVALID'],
));
}
private function createUser(Role $role): User
{
return User::creer(
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service;
use App\Administration\Application\Port\ActiveRoleStore;
use App\Administration\Application\Service\RoleContext;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use DomainException;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RoleContextTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryActiveRoleStore $store;
private RoleContext $roleContext;
protected function setUp(): void
{
$this->store = new InMemoryActiveRoleStore();
$this->roleContext = new RoleContext($this->store);
}
#[Test]
public function itReturnsPrimaryRoleWhenNoActiveRoleStored(): void
{
$user = $this->createUser(Role::PROF);
self::assertSame(Role::PROF, $this->roleContext->getActiveRole($user));
}
#[Test]
public function itSwitchesToAnotherRole(): void
{
$user = $this->createActiveUser(Role::PROF);
$user->attribuerRole(Role::ADMIN, new DateTimeImmutable());
$this->roleContext->switchTo($user, Role::ADMIN);
self::assertSame(Role::ADMIN, $this->roleContext->getActiveRole($user));
}
#[Test]
public function itThrowsWhenSwitchingToUnassignedRole(): void
{
$user = $this->createActiveUser(Role::PROF);
$this->expectException(RoleNonAttribueException::class);
$this->roleContext->switchTo($user, Role::ADMIN);
}
#[Test]
public function itClearsActiveRole(): void
{
$user = $this->createActiveUser(Role::PROF);
$user->attribuerRole(Role::ADMIN, new DateTimeImmutable());
$this->roleContext->switchTo($user, Role::ADMIN);
$this->roleContext->clear($user);
self::assertSame(Role::PROF, $this->roleContext->getActiveRole($user));
}
#[Test]
public function itThrowsWhenAccountIsNotActive(): void
{
$user = $this->createActiveUser(Role::PROF);
$user->attribuerRole(Role::ADMIN, new DateTimeImmutable());
$user->bloquer('Test', new DateTimeImmutable());
$this->expectException(DomainException::class);
$this->roleContext->switchTo($user, Role::ADMIN);
}
private function createUser(Role $role): User
{
return User::creer(
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
private function createActiveUser(Role $role): User
{
return User::reconstitute(
id: UserId::generate(),
email: new Email('active@example.com'),
roles: [$role],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
hashedPassword: 'hashed',
activatedAt: new DateTimeImmutable('2026-01-16 10:00:00'),
consentementParental: null,
);
}
}
/**
* @internal
*/
final class InMemoryActiveRoleStore implements ActiveRoleStore
{
/** @var array<string, Role> */
private array $roles = [];
public function store(User $user, Role $role): void
{
$this->roles[(string) $user->id] = $role;
}
public function get(User $user): ?Role
{
return $this->roles[(string) $user->id] ?? null;
}
public function clear(User $user): void
{
unset($this->roles[(string) $user->id]);
}
}

View File

@@ -176,7 +176,7 @@ final class UserInvitationTest extends TestCase
$user = User::reconstitute(
id: UserId::generate(),
email: new Email('minor@example.com'),
role: Role::ELEVE,
roles: [Role::ELEVE],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
statut: StatutCompte::CONSENTEMENT_REQUIS,

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\User;
use App\Administration\Domain\Event\RoleAttribue;
use App\Administration\Domain\Event\RoleRetire;
use App\Administration\Domain\Exception\DernierRoleNonRetirableException;
use App\Administration\Domain\Exception\RoleDejaAttribueException;
use App\Administration\Domain\Exception\RoleNonAttribueException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UserRoleTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string SCHOOL_NAME = 'École Alpha';
#[Test]
public function userIsCreatedWithSingleRole(): void
{
$user = $this->createUser(Role::PROF);
self::assertSame([Role::PROF], $user->roles);
}
#[Test]
public function attribuerRoleAddsRoleToUser(): void
{
$user = $this->createUser(Role::PROF);
$user->pullDomainEvents();
$user->attribuerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00'));
self::assertContains(Role::PROF, $user->roles);
self::assertContains(Role::PARENT, $user->roles);
self::assertCount(2, $user->roles);
}
#[Test]
public function attribuerRoleRecordsRoleAttribueEvent(): void
{
$user = $this->createUser(Role::PROF);
$user->pullDomainEvents();
$user->attribuerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00'));
$events = $user->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RoleAttribue::class, $events[0]);
}
#[Test]
public function attribuerRoleThrowsWhenRoleAlreadyAssigned(): void
{
$user = $this->createUser(Role::PROF);
$this->expectException(RoleDejaAttribueException::class);
$user->attribuerRole(Role::PROF, new DateTimeImmutable());
}
#[Test]
public function retirerRoleRemovesRoleFromUser(): void
{
$user = $this->createUser(Role::PROF);
$user->attribuerRole(Role::PARENT, new DateTimeImmutable());
$user->pullDomainEvents();
$user->retirerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00'));
self::assertSame([Role::PROF], $user->roles);
}
#[Test]
public function retirerRoleRecordsRoleRetireEvent(): void
{
$user = $this->createUser(Role::PROF);
$user->attribuerRole(Role::PARENT, new DateTimeImmutable());
$user->pullDomainEvents();
$user->retirerRole(Role::PARENT, new DateTimeImmutable('2026-02-08 10:00:00'));
$events = $user->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(RoleRetire::class, $events[0]);
}
#[Test]
public function retirerRoleThrowsWhenRoleNotAssigned(): void
{
$user = $this->createUser(Role::PROF);
$this->expectException(RoleNonAttribueException::class);
$user->retirerRole(Role::PARENT, new DateTimeImmutable());
}
#[Test]
public function retirerRoleThrowsWhenLastRole(): void
{
$user = $this->createUser(Role::PROF);
$this->expectException(DernierRoleNonRetirableException::class);
$user->retirerRole(Role::PROF, new DateTimeImmutable());
}
#[Test]
public function userCanHaveMultipleRoles(): void
{
$user = $this->createUser(Role::PROF);
$user->attribuerRole(Role::PARENT, new DateTimeImmutable());
$user->attribuerRole(Role::VIE_SCOLAIRE, new DateTimeImmutable());
self::assertCount(3, $user->roles);
self::assertContains(Role::PROF, $user->roles);
self::assertContains(Role::PARENT, $user->roles);
self::assertContains(Role::VIE_SCOLAIRE, $user->roles);
}
#[Test]
public function aLeRoleReturnsTrueForAssignedRole(): void
{
$user = $this->createUser(Role::PROF);
self::assertTrue($user->aLeRole(Role::PROF));
self::assertFalse($user->aLeRole(Role::PARENT));
}
#[Test]
public function rolePrincipalReturnsFirstRole(): void
{
$user = $this->createUser(Role::PROF);
self::assertSame(Role::PROF, $user->rolePrincipal());
}
private function createUser(Role $role = Role::PARENT): User
{
return User::creer(
email: new Email('user@example.com'),
role: $role,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: self::SCHOOL_NAME,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
);
}
}

View File

@@ -200,10 +200,13 @@ final class LogoutControllerTest extends TestCase
// THEN: Cookies are cleared (expired)
$cookies = $response->headers->getCookies();
$this->assertCount(2, $cookies); // /api and /api/token (legacy)
$this->assertCount(3, $cookies); // refresh_token /api, /api/token (legacy), classeo_sid
$cookieNames = array_map(static fn ($c) => $c->getName(), $cookies);
$this->assertContains('refresh_token', $cookieNames);
$this->assertContains('classeo_sid', $cookieNames);
foreach ($cookies as $cookie) {
$this->assertSame('refresh_token', $cookie->getName());
$this->assertSame('', $cookie->getValue());
$this->assertTrue($cookie->isCleared()); // Expiry in the past
}

View File

@@ -185,7 +185,7 @@ final class CreateTestActivationTokenCommandTest extends TestCase
return User::reconstitute(
id: UserId::generate(),
email: new Email($email),
role: Role::PARENT,
roles: [Role::PARENT],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,

View File

@@ -226,7 +226,7 @@ final class DatabaseUserProviderTest extends TestCase
return User::reconstitute(
id: UserId::generate(),
email: new Email('user@example.com'),
role: Role::PARENT,
roles: [Role::PARENT],
tenantId: $tenantId,
schoolName: 'École Test',
statut: $statut,

View File

@@ -133,12 +133,12 @@ final class LoginSuccessHandlerTest extends TestCase
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: Refresh token cookie is set
// THEN: Refresh token cookie and session ID cookie are set
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertSame('refresh_token', $cookies[0]->getName());
$this->assertTrue($cookies[0]->isHttpOnly());
$this->assertSame('/api', $cookies[0]->getPath());
$this->assertCount(2, $cookies);
$cookieNames = array_map(static fn ($c) => $c->getName(), $cookies);
$this->assertContains('refresh_token', $cookieNames);
$this->assertContains('classeo_sid', $cookieNames);
// THEN: Refresh token is saved in repository
$this->assertTrue(
@@ -304,10 +304,12 @@ final class LoginSuccessHandlerTest extends TestCase
// WHEN: Handler processes the event
$this->handler->onAuthenticationSuccess($event);
// THEN: Cookie is NOT marked as secure (HTTP)
// THEN: Cookies are NOT marked as secure (HTTP)
$cookies = $response->headers->getCookies();
$this->assertCount(1, $cookies);
$this->assertFalse($cookies[0]->isSecure());
$this->assertCount(2, $cookies);
foreach ($cookies as $cookie) {
$this->assertFalse($cookie->isSecure());
}
}
private function createRequest(): Request

View File

@@ -84,7 +84,7 @@ final class SecurityUserTest extends TestCase
return User::reconstitute(
id: UserId::generate(),
email: new Email('user@example.com'),
role: $role,
roles: [$role],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,

View File

@@ -121,6 +121,48 @@ final class UserVoterTest extends TestCase
self::assertSame(UserVoter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsManageRolesToAdmin(): void
{
$result = $this->voteWithRole('ROLE_ADMIN', UserVoter::MANAGE_ROLES);
self::assertSame(UserVoter::ACCESS_GRANTED, $result);
}
#[Test]
public function itGrantsManageRolesToSuperAdmin(): void
{
$result = $this->voteWithRole('ROLE_SUPER_ADMIN', UserVoter::MANAGE_ROLES);
self::assertSame(UserVoter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesManageRolesToProf(): void
{
$result = $this->voteWithRole('ROLE_PROF', UserVoter::MANAGE_ROLES);
self::assertSame(UserVoter::ACCESS_DENIED, $result);
}
#[Test]
public function itDeniesManageRolesToSecretariat(): void
{
$result = $this->voteWithRole('ROLE_SECRETARIAT', UserVoter::MANAGE_ROLES);
self::assertSame(UserVoter::ACCESS_DENIED, $result);
}
#[Test]
public function itGrantsResendInvitationToAdmin(): void
{
$result = $this->voteWithRole('ROLE_ADMIN', UserVoter::RESEND_INVITATION);
self::assertSame(UserVoter::ACCESS_GRANTED, $result);
}
#[Test]
public function itDeniesResendInvitationToProf(): void
{
$result = $this->voteWithRole('ROLE_PROF', UserVoter::RESEND_INVITATION);
self::assertSame(UserVoter::ACCESS_DENIED, $result);
}
private function voteWithRole(string $role, string $attribute): int
{
$user = $this->createMock(UserInterface::class);