feat: Liaison parents-enfants avec gestion des tuteurs

Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes,
emploi du temps, devoirs). Cela nécessite un lien formalisé entre le
compte parent et le compte élève, géré par les administrateurs.

Le lien est établi soit manuellement via l'interface d'administration,
soit automatiquement lors de l'activation du compte parent lorsque
l'invitation inclut un élève cible. Ce lien conditionne l'accès aux
données scolaires de l'enfant (autorisations vérifiées par un voter
dédié).
This commit is contained in:
2026-02-12 08:38:19 +01:00
parent e930c505df
commit 44ebe5e511
91 changed files with 10071 additions and 39 deletions

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\StudentGuardianRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function in_array;
use InvalidArgumentException;
use function is_string;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter pour l'accès aux données d'un élève.
*
* Un parent ne peut voir que les données de ses enfants liés.
* Le personnel de l'établissement a accès à tous les élèves.
*
* @extends Voter<string, string>
*/
final class StudentGuardianVoter extends Voter
{
public const string VIEW_STUDENT = 'STUDENT_GUARDIAN_VIEW_STUDENT';
public const string MANAGE = 'STUDENT_GUARDIAN_MANAGE';
public function __construct(
private readonly StudentGuardianRepository $repository,
private readonly TenantContext $tenantContext,
) {
}
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if ($attribute === self::VIEW_STUDENT && is_string($subject)) {
return true;
}
return $attribute === self::MANAGE && $subject === null;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
$roles = $user->getRoles();
if ($attribute === self::MANAGE) {
return $this->isStaff($roles);
}
if ($this->isStaff($roles)) {
return true;
}
if ($this->isParent($roles)) {
return $this->parentIsLinkedToStudent($user->userId(), $subject);
}
return false;
}
/**
* @param string[] $roles
*/
private function isStaff(array $roles): bool
{
return $this->hasAnyRole($roles, [
Role::SUPER_ADMIN->value,
Role::ADMIN->value,
Role::SECRETARIAT->value,
Role::PROF->value,
Role::VIE_SCOLAIRE->value,
]);
}
/**
* @param string[] $roles
*/
private function isParent(array $roles): bool
{
return in_array(Role::PARENT->value, $roles, true);
}
private function parentIsLinkedToStudent(string $guardianId, string $studentId): bool
{
if (!$this->tenantContext->hasTenant()) {
return false;
}
try {
$tenantId = $this->tenantContext->getCurrentTenantId();
$link = $this->repository->findByStudentAndGuardian(
UserId::fromString($studentId),
UserId::fromString($guardianId),
TenantId::fromString((string) $tenantId),
);
return $link !== null;
} catch (InvalidArgumentException) {
return false;
}
}
/**
* @param string[] $userRoles
* @param string[] $allowedRoles
*/
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
{
foreach ($userRoles as $role) {
if (in_array($role, $allowedRoles, true)) {
return true;
}
}
return false;
}
}