handle right to see confidential course on regulation list

This commit is contained in:
Julien Fastré 2023-07-05 16:23:14 +02:00
parent a56370d851
commit a990591e0c
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
4 changed files with 229 additions and 37 deletions

View File

@ -78,6 +78,7 @@ class AccompanyingPeriodRegulationListController
$form['jobs']->getData(), $form['jobs']->getData(),
$form['services']->getData(), $form['services']->getData(),
$form['locations']->getData(), $form['locations']->getData(),
['openingDate' => 'DESC', 'id' => 'DESC'],
$paginator->getItemsPerPage(), $paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber() $paginator->getCurrentPageFirstItemNumber()
); );

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository; namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
@ -26,6 +27,8 @@ use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use DateTime; use DateTime;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Repository\AccompanyingPeriodACLAwareRepositoryTest; use Repository\AccompanyingPeriodACLAwareRepositoryTest;
@ -107,9 +110,16 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
return $qb; return $qb;
} }
/**
* @throws NonUniqueResultException
* @throws NoResultException
*/
public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int public function countByUnDispatched(array $jobs, array $services, array $administrativeLocations): int
{ {
$qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); $qb = $this->addACLByUnDispatched(
$this->buildQueryUnDispatched($jobs, $services, $administrativeLocations),
$this->buildCenterOnScope()
);
$qb->select('COUNT(ap)'); $qb->select('COUNT(ap)');
@ -194,6 +204,8 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
} }
/** /**
* Add clause for scope on a query, based on no
*
* @param QueryBuilder $qb where the accompanying period have the `ap` alias * @param QueryBuilder $qb where the accompanying period have the `ap` alias
* @param array<Scope> $scopesCanSee * @param array<Scope> $scopesCanSee
* @param array<Scope> $scopesCanSeeConfidential * @param array<Scope> $scopesCanSeeConfidential
@ -252,19 +264,27 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
return $qb; return $qb;
} }
public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array public function buildCenterOnScope(): array
{ {
$qb = $this->addACLByUnDispatched($this->buildQueryUnDispatched($jobs, $services, $administrativeLocations)); $centerOnScopes = [];
foreach ($this->authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE) as $center) {
$centerOnScopes[] = [
'center' => $center,
'scopeOnRole' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center),
'scopeCanSeeConfidential' => $this->authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center),
];
}
return $centerOnScopes;
}
public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryUnDispatched($jobs, $services, $administrativeAdministrativeLocations);
$qb->select('ap'); $qb->select('ap');
if (null !== $limit) { $qb = $this->addACLByUnDispatched($qb, $this->buildCenterOnScope(), false);
$qb->setMaxResults($limit); $qb = $this->addOrderLimitClauses($qb, $orderBy, $limit, $offset);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@ -305,41 +325,73 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
private function addACLByUnDispatched(QueryBuilder $qb): QueryBuilder /**
* @param QueryBuilder $qb
* @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes
* @param bool $allowNoCenter if true, will allow to see the periods linked to person which does not have any center. Very few edge case when some Person are not associated to a center.
* @return QueryBuilder
*/
public function addACLByUnDispatched(QueryBuilder $qb, array $centerScopes, bool $allowNoCenter = false): QueryBuilder
{ {
$centers = $this->authorizationHelper->getReachableCenters( $user = $this->security->getUser();
AccompanyingPeriodVoter::SEE
);
$orX = $qb->expr()->orX(); if (0 === count($centerScopes) || !$user instanceof User) {
if (0 === count($centers)) {
return $qb->andWhere("'FALSE' = 'TRUE'"); return $qb->andWhere("'FALSE' = 'TRUE'");
} }
foreach ($centers as $key => $center) { $orX = $qb->expr()->orX();
$scopes = $this->authorizationHelper
->getReachableScopes(
AccompanyingPeriodVoter::SEE,
$center
);
$idx = 0;
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$and = $qb->expr()->andX( $and = $qb->expr()->andX(
$qb->expr()->exists('SELECT part FROM ' . AccompanyingPeriodParticipation::class . ' part ' . $qb->expr()->exists(
"JOIN part.person p WHERE part.accompanyingPeriod = ap.id AND p.center = :center_{$key}") 'SELECT 1 FROM ' . AccompanyingPeriodParticipation::class . " part_{$idx} " .
"JOIN part_{$idx}.person p{$idx} LEFT JOIN p{$idx}.centerCurrent centerCurrent_{$idx} " .
"WHERE part_{$idx}.accompanyingPeriod = ap.id AND (centerCurrent_{$idx}.center = :center_{$idx}"
. ($allowNoCenter ? " OR centerCurrent_{$idx}.id IS NULL)" : ")")
)
); );
$qb->setParameter('center_' . $key, $center); $qb->setParameter('center_' . $idx, $center);
$orScope = $qb->expr()->orX();
foreach ($scopes as $skey => $scope) { $orScopeInsideCenter = $qb->expr()->orX(
$orScope->add( // even if the scope is not in one authorized, the user can see the course if it is in DRAFT state
$qb->expr()->isMemberOf(':scope_' . $key . '_' . $skey, 'ap.scopes') $qb->expr()->eq('ap.step', ':draft')
);
$idx++;
foreach ($scopes as $scope) {
// for each scope:
// - either the user is the referrer of the course
// - or the accompanying course is one of the reachable scopes
// - and the parcours is not confidential OR the user is the referrer OR the user can see the confidential course
$orOnScope = $qb->expr()->orX(
$qb->expr()->isMemberOf(':scope_' . $idx, 'ap.scopes'),
$qb->expr()->eq('ap.user', ':user')
); );
$qb->setParameter('scope_' . $key . '_' . $skey, $scope); $qb->setParameter('user', $user);
if (in_array($scope, $scopesCanSeeConfidential, true)) {
$orScopeInsideCenter->add($orOnScope);
} else {
// we must add a condition: the course is not confidential or the user is the referrer
$andXOnScope = $qb->expr()->andX(
$orOnScope,
$qb->expr()->orX(
'ap.confidential = FALSE',
$qb->expr()->eq('ap.user', ':user')
)
);
$orScopeInsideCenter->add($andXOnScope);
}
$qb->setParameter('scope_' . $idx, $scope);
$idx++;
} }
$and->add($orScope); $and->add($orScopeInsideCenter);
$orX->add($and); $orX->add($and);
$idx++;
} }
return $qb->andWhere($orX); return $qb->andWhere($orX);
@ -350,7 +402,7 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
* @param array|Scope[] $services * @param array|Scope[] $services
* @param array|Location[] $locations * @param array|Location[] $locations
*/ */
private function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder public function buildQueryUnDispatched(array $jobs, array $services, array $locations): QueryBuilder
{ {
$qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap'); $qb = $this->accompanyingPeriodRepository->createQueryBuilder('ap');
@ -378,8 +430,8 @@ final readonly class AccompanyingPeriodACLAwareRepository implements Accompanyin
$or = $qb->expr()->orX(); $or = $qb->expr()->orX();
foreach ($services as $key => $service) { foreach ($services as $key => $service) {
$or->add($qb->expr()->isMemberOf(':scope_' . $key, 'ap.scopes')); $or->add($qb->expr()->isMemberOf(':scopef_' . $key, 'ap.scopes'));
$qb->setParameter('scope_' . $key, $service); $qb->setParameter('scopef_' . $key, $service);
} }
$qb->andWhere($or); $qb->andWhere($or);
} }

View File

@ -50,7 +50,7 @@ interface AccompanyingPeriodACLAwareRepositoryInterface
* *
* @return list<AccompanyingPeriod> * @return list<AccompanyingPeriod>
*/ */
public function findByUnDispatched(array $jobs, array $services, array $administrativeLocations, ?int $limit = null, ?int $offset = null): array; public function findByUnDispatched(array $jobs, array $services, array $administrativeAdministrativeLocations, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
/** /**
* @param array|PostalCode[] $postalCodes * @param array|PostalCode[] $postalCodes

View File

@ -11,8 +11,10 @@ declare(strict_types=1);
namespace Repository; namespace Repository;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\ScopeRepositoryInterface; use Chill\MainBundle\Repository\ScopeRepositoryInterface;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
@ -35,10 +37,13 @@ use Symfony\Component\Workflow\Registry;
class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase
{ {
use ProphecyTrait; use ProphecyTrait;
private AccompanyingPeriodRepository $accompanyingPeriodRepository; private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private CenterResolverManagerInterface $centerResolverManager; private CenterResolverManagerInterface $centerResolverManager;
private CenterRepositoryInterface $centerRepository;
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private ScopeRepositoryInterface $scopeRepository; private ScopeRepositoryInterface $scopeRepository;
@ -51,11 +56,11 @@ class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase
{ {
self::bootKernel(); self::bootKernel();
$this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class); $this->accompanyingPeriodRepository = self::$container->get(AccompanyingPeriodRepository::class);
$this->centerRepository = self::$container->get(CenterRepositoryInterface::class);
$this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class); $this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class);
$this->entityManager = self::$container->get(EntityManagerInterface::class); $this->entityManager = self::$container->get(EntityManagerInterface::class);
$this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class); $this->scopeRepository = self::$container->get(ScopeRepositoryInterface::class);
$this->registry = self::$container->get(Registry::class); $this->registry = self::$container->get(Registry::class);
} }
public static function tearDownAfterClass(): void public static function tearDownAfterClass(): void
@ -78,6 +83,140 @@ class AccompanyingPeriodACLAwareRepositoryTest extends KernelTestCase
$em->flush(); $em->flush();
} }
/**
* @dataProvider provideDataFindByUndispatched
* @param list<array{center: Center, scopeOnRole: list<Scope>, scopeCanSeeConfidential: list<Scope>}> $centerScopes
* @param list<AccompanyingPeriod> $expectedContains
* @param list<AccompanyingPeriod> $expectedNotContains
*/
public function testFindByUndispatched(User $user, array $centerScopes, array $expectedContains, array $expectedNotContains, string $message): void
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class);
$centers = [];
foreach ($centerScopes as ['center' => $center, 'scopeOnRole' => $scopes, 'scopeCanSeeConfidential' => $scopesCanSeeConfidential]) {
$centers[spl_object_hash($center)] = $center;
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE, $center)
->willReturn($scopes);
$authorizationHelper->getReachableScopes(AccompanyingPeriodVoter::SEE_CONFIDENTIAL_ALL, $center)
->willReturn($scopesCanSeeConfidential);
}
$authorizationHelper->getReachableCenters(AccompanyingPeriodVoter::SEE)->willReturn(array_values($centers));
$repository = new AccompanyingPeriodACLAwareRepository(
$this->accompanyingPeriodRepository,
$security->reveal(),
$authorizationHelper->reveal(),
$this->centerResolverManager
);
$actual = array_map(
fn (AccompanyingPeriod $period) => $period->getId(),
$repository->findByUnDispatched([], [], [], ['id' => 'DESC'], 20, 0)
);
foreach ($expectedContains as $expected) {
self::assertContains($expected->getId(), $actual, $message);
}
foreach ($expectedNotContains as $expected) {
self::assertNotContains($expected->getId(), $actual, $message);
}
}
public function provideDataFindByUndispatched(): iterable
{
$this->setUp();
if (null === $user = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u")->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
if (null === $anotherUser = $this->entityManager->createQuery("SELECT u FROM " . User::class . " u WHERE u.id != :uid")->setParameter('uid', $user->getId())
->setMaxResults(1)->getSingleResult()) {
throw new \RuntimeException("no user found");
}
/** @var Person $person */
[$person, $anotherPerson, $person2, $person3] = $this->entityManager
->createQuery("SELECT p FROM " . Person::class . " p ")
->setMaxResults(4)
->getResult();
if (null === $person || null === $anotherPerson || null === $person2 || null === $person3) {
throw new \RuntimeException("no person found");
}
$scopes = $this->scopeRepository->findAll();
if (3 > count($scopes)) {
throw new \RuntimeException("not enough scopes for this test");
}
$scopesCanSee = [ $scopes[0] ];
$scopesGroup2 = [ $scopes[1] ];
$centers = $this->centerRepository->findActive();
if (2 > count($centers)) {
throw new \RuntimeException("not enough centers for this test");
}
$period = $this->buildPeriod($person, $scopesCanSee, $user, true);
// expected scope: can see the period
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[$period],
[],
"period should be visible with expected scopes",
];
// no scope visible
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible without expected scopes",
];
// another center
yield [
$anotherUser,
[
[
'center' => $person->getCenter(),
'scopeOnRole' => $scopesGroup2,
'scopeCanSeeConfidential' => [],
],
[
'center' => array_values(array_filter($centers, fn (Center $c) => $c !== $person->getCenter()))[0],
'scopeOnRole' => $scopesCanSee,
'scopeCanSeeConfidential' => [],
],
],
[],
[$period],
"period should not be visible for user having right in another scope (with multiple centers)"
];
$this->entityManager->flush();
}
/** /**
* For testing this method, we mock the authorization helper to return different Scope that a user * For testing this method, we mock the authorization helper to return different Scope that a user