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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user