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

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