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

@@ -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']),