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

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