Merge branch '286-storedobject-voter' into 'signature-app-master'

Adjust behavoir of voters for stored objects

See merge request Chill-Projet/chill-bundles!701
This commit is contained in:
Julien Fastré 2024-07-16 12:01:28 +00:00
commit db73dcffc7
49 changed files with 999 additions and 143 deletions

View File

@ -0,0 +1,7 @@
kind: Feature
body: The behavoir of the voters for stored objects is adjusted so as to limit edit
and delete possibilities to users related to the activity, social action or workflow
entity.
time: 2024-06-14T15:35:37.582159301+02:00
custom:
Issue: "286"

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Repository; namespace Chill\ActivityBundle\Repository;
use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\Activity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@ -23,7 +25,7 @@ use Doctrine\Persistence\ManagerRegistry;
* @method Activity[] findAll() * @method Activity[] findAll()
* @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) * @method Activity[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/ */
class ActivityRepository extends ServiceEntityRepository class ActivityRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
@ -97,4 +99,16 @@ class ActivityRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Activity
{
$qb = $this->createQueryBuilder('a');
$query = $qb
->leftJoin('a.documents', 'ad')
->where('ad.id = :storedObjectId')
->setParameter('storedObjectId', $storedObject->getId())
->getQuery();
return $query->getOneOrNullResult();
}
} }

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ActivityBundle\Security\Authorization;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class ActivityStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly ActivityRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return Activity::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => ActivityVoter::UPDATE,
StoredObjectRoleEnum::SEE => ActivityVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return false;
}
}

View File

@ -89,6 +89,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
$g = new SignedUrlPost( $g = new SignedUrlPost(
$url = $this->generateUrl($object_name), $url = $this->generateUrl($object_name),
$expires, $expires,
$object_name,
$this->max_post_file_size, $this->max_post_file_size,
$max_file_count, $max_file_count,
$submit_delay, $submit_delay,
@ -127,7 +128,7 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
]; ];
$url = $url.'?'.\http_build_query($args); $url = $url.'?'.\http_build_query($args);
$signature = new SignedUrl(strtoupper($method), $url, $expires); $signature = new SignedUrl(strtoupper($method), $url, $expires, $object_name);
$this->event_dispatcher->dispatch( $this->event_dispatcher->dispatch(
new TempUrlGenerateEvent($signature) new TempUrlGenerateEvent($signature)

View File

@ -21,6 +21,8 @@ readonly class SignedUrl
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
public string $url, public string $url,
public \DateTimeImmutable $expires, public \DateTimeImmutable $expires,
#[Serializer\Groups(['read'])]
public string $object_name,
) {} ) {}
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]

View File

@ -18,6 +18,7 @@ readonly class SignedUrlPost extends SignedUrl
public function __construct( public function __construct(
string $url, string $url,
\DateTimeImmutable $expires, \DateTimeImmutable $expires,
string $object_name,
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
public int $max_file_size, public int $max_file_size,
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
@ -31,6 +32,6 @@ readonly class SignedUrlPost extends SignedUrl
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
public string $signature, public string $signature,
) { ) {
parent::__construct('POST', $url, $expires); parent::__construct('POST', $url, $expires, $object_name);
} }
} }

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\DependencyInjection;
use Chill\DocStoreBundle\Controller\StoredObjectApiController; use Chill\DocStoreBundle\Controller\StoredObjectApiController;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@ -35,6 +36,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$container->setParameter('chill_doc_store', $config); $container->setParameter('chill_doc_store', $config);
$container->registerForAutoconfiguration(StoredObjectVoterInterface::class)->addTag('stored_object_voter');
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml'); $loader->load('services.yaml');
$loader->load('services/controller.yaml'); $loader->load('services/controller.yaml');
@ -42,6 +45,7 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf
$loader->load('services/fixtures.yaml'); $loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml'); $loader->load('services/form.yaml');
$loader->load('services/templating.yaml'); $loader->load('services/templating.yaml');
$loader->load('services/security.yaml');
} }
public function prepend(ContainerBuilder $container) public function prepend(ContainerBuilder $container)

View File

@ -12,13 +12,14 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository; namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
class AccompanyingCourseDocumentRepository implements ObjectRepository class AccompanyingCourseDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{ {
private readonly EntityRepository $repository; private readonly EntityRepository $repository;
@ -45,6 +46,16 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $qb->getQuery()->getSingleScalarResult(); return $qb->getQuery()->getSingleScalarResult();
} }
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
public function find($id): ?AccompanyingCourseDocument public function find($id): ?AccompanyingCourseDocument
{ {
return $this->repository->find($id); return $this->repository->find($id);
@ -55,7 +66,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $this->repository->findAll(); return $this->repository->findAll();
} }
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{ {
return $this->repository->findBy($criteria, $orderBy, $limit, $offset); return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
} }
@ -65,7 +76,7 @@ class AccompanyingCourseDocumentRepository implements ObjectRepository
return $this->findOneBy($criteria); return $this->findOneBy($criteria);
} }
public function getClassName() public function getClassName(): string
{ {
return AccompanyingCourseDocument::class; return AccompanyingCourseDocument::class;
} }

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
interface AssociatedEntityToStoredObjectInterface
{
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object;
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Repository; namespace Chill\DocStoreBundle\Repository;
use Chill\DocStoreBundle\Entity\PersonDocument; use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
@ -19,7 +20,7 @@ use Doctrine\Persistence\ObjectRepository;
/** /**
* @template ObjectRepository<PersonDocument::class> * @template ObjectRepository<PersonDocument::class>
*/ */
readonly class PersonDocumentRepository implements ObjectRepository readonly class PersonDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
@ -53,4 +54,14 @@ readonly class PersonDocumentRepository implements ObjectRepository
{ {
return PersonDocument::class; return PersonDocument::class;
} }
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->repository->createQueryBuilder('d');
$query = $qb->where('d.object = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
} }

View File

@ -71,7 +71,7 @@
</li> </li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %} {% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_SEE_DETAILS', document) %}
<li> <li>
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document)) }} {{ document.object|chill_document_button_group(document.title) }}
</li> </li>
<li> <li>
<a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a> <a href="{{ chill_path_add_return_path('accompanying_course_document_show', {'course': accompanyingCourse.id, 'id': document.id}) }}" class="btn btn-show"></a>
@ -90,7 +90,7 @@
{% else %} {% else %}
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %} {% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li> <li>
{{ document.object|chill_document_button_group(document.title, is_granted('CHILL_PERSON_DOCUMENT_UPDATE', document)) }} {{ document.object|chill_document_button_group(document.title) }}
</li> </li>
<li> <li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a> <a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="btn btn-show"></a>

View File

@ -70,12 +70,12 @@ class AccompanyingCourseDocumentVoter extends AbstractChillVoter implements Prov
return []; return [];
} }
protected function supports($attribute, $subject): bool public function supports($attribute, $subject): bool
{ {
return $this->voterHelper->supports($attribute, $subject); return $this->voterHelper->supports($attribute, $subject);
} }
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{ {
if (!$token->getUser() instanceof User) { if (!$token->getUser() instanceof User) {
return false; return false;

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization; namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl; use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
@ -22,6 +23,7 @@ final class AsyncUploadVoter extends Voter
public function __construct( public function __construct(
private readonly Security $security, private readonly Security $security,
private readonly StoredObjectRepository $storedObjectRepository
) {} ) {}
protected function supports($attribute, $subject): bool protected function supports($attribute, $subject): bool
@ -32,10 +34,16 @@ final class AsyncUploadVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{ {
/** @var SignedUrl $subject */ /** @var SignedUrl $subject */
if (!in_array($subject->method, ['POST', 'GET', 'HEAD'], true)) { if (!in_array($subject->method, ['POST', 'GET', 'HEAD', 'PUT'], true)) {
return false; return false;
} }
return $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN'); $storedObject = $this->storedObjectRepository->findOneBy(['filename' => $subject->object_name]);
return match ($subject->method) {
'GET', 'HEAD' => $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject),
'PUT' => $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject),
'POST' => $this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')
};
} }
} }

View File

@ -12,9 +12,9 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Security\Authorization; namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
/** /**
* Voter for the content of a stored object. * Voter for the content of a stored object.
@ -23,6 +23,8 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*/ */
class StoredObjectVoter extends Voter class StoredObjectVoter extends Voter
{ {
public function __construct(private readonly Security $security, private readonly iterable $storedObjectVoters) {}
protected function supports($attribute, $subject): bool protected function supports($attribute, $subject): bool
{ {
return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum return StoredObjectRoleEnum::tryFrom($attribute) instanceof StoredObjectRoleEnum
@ -32,24 +34,22 @@ class StoredObjectVoter extends Voter
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{ {
/** @var StoredObject $subject */ /** @var StoredObject $subject */
if ( $attributeAsEnum = StoredObjectRoleEnum::from($attribute);
!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)
|| $subject->getUuid()->toString() !== $token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT) // Loop through context-specific voters
) { foreach ($this->storedObjectVoters as $storedObjectVoter) {
return false; if ($storedObjectVoter->supports($attributeAsEnum, $subject)) {
return $storedObjectVoter->voteOnAttribute($attributeAsEnum, $subject, $token);
}
} }
if (!$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)) { // User role-based fallback
return false; if ($this->security->isGranted('ROLE_USER') || $this->security->isGranted('ROLE_ADMIN')) {
// TODO: this maybe considered as a security issue, as all authenticated users can reach a stored object which
// is potentially detached from an existing entity.
return true;
} }
$askedRole = StoredObjectRoleEnum::from($attribute); return false;
$tokenRoleAuthorization =
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS);
return match ($askedRole) {
StoredObjectRoleEnum::SEE => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization || StoredObjectRoleEnum::SEE === $tokenRoleAuthorization,
StoredObjectRoleEnum::EDIT => StoredObjectRoleEnum::EDIT === $tokenRoleAuthorization
};
} }
} }

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
{
abstract protected function getRepository(): AssociatedEntityToStoredObjectInterface;
/**
* @return class-string
*/
abstract protected function getClass(): string;
abstract protected function attributeToRole(StoredObjectRoleEnum $attribute): string;
abstract protected function canBeAssociatedWithWorkflow(): bool;
public function __construct(
private readonly Security $security,
private readonly ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null,
) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
{
$class = $this->getClass();
return $this->getRepository()->findAssociatedEntityToStoredObject($subject) instanceof $class;
}
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
// Retrieve the related accompanying course document
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
// Determine the attribute to pass to AccompanyingCourseDocumentVoter
$voterAttribute = $this->attributeToRole($attribute);
if (false === $this->security->isGranted($voterAttribute, $entity)) {
return false;
}
if ($this->canBeAssociatedWithWorkflow()) {
if (null === $this->workflowDocumentService) {
throw new \LogicException('Provide a workflow document service');
}
return $this->workflowDocumentService->notBlockedByWorkflow($entity);
}
return true;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
final class AccompanyingCourseDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly AccompanyingCourseDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => AccompanyingCourseDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => AccompanyingCourseDocumentVoter::SEE_DETAILS,
};
}
protected function getClass(): string
{
return AccompanyingCourseDocument::class;
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Repository\PersonDocumentRepository;
use Chill\DocStoreBundle\Security\Authorization\PersonDocumentVoter;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Symfony\Component\Security\Core\Security;
class PersonDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly PersonDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return PersonDocument::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => PersonDocumentVoter::UPDATE,
StoredObjectRoleEnum::SEE => PersonDocumentVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
interface StoredObjectVoterInterface
{
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool;
}

View File

@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@ -32,7 +33,8 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
public function __construct( public function __construct(
private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider, private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider,
private readonly UrlGeneratorInterface $urlGenerator private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security
) {} ) {}
public function normalize($object, ?string $format = null, array $context = []) public function normalize($object, ?string $format = null, array $context = [])
@ -55,13 +57,13 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
// deprecated property // deprecated property
$datas['creationDate'] = $datas['createdAt']; $datas['creationDate'] = $datas['createdAt'];
$canDavSee = in_array(self::ADD_DAV_SEE_LINK_CONTEXT, $context['groups'] ?? [], true); $canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object);
$canDavEdit = in_array(self::ADD_DAV_EDIT_LINK_CONTEXT, $context['groups'] ?? [], true); $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object);
if ($canDavSee || $canDavEdit) { if ($canSee || $canEdit) {
$accessToken = $this->JWTDavTokenProvider->createToken( $accessToken = $this->JWTDavTokenProvider->createToken(
$object, $object,
$canDavEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE
); );
$datas['_links'] = [ $datas['_links'] = [

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Service;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Security;
class WorkflowStoredObjectPermissionHelper
{
public function __construct(private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager) {}
public function notBlockedByWorkflow(object $entity): bool
{
$workflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
$currentUser = $this->security->getUser();
foreach ($workflows as $workflow) {
if ($workflow->isFinal()) {
return false;
}
if (!$workflow->getCurrentStep()->getAllDestUser()->contains($currentUser)) {
return false;
}
}
return true;
}
}

View File

@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment; use Twig\Environment;
@ -128,6 +129,7 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
private NormalizerInterface $normalizer, private NormalizerInterface $normalizer,
private JWTDavTokenProviderInterface $davTokenProvider, private JWTDavTokenProviderInterface $davTokenProvider,
private UrlGeneratorInterface $urlGenerator, private UrlGeneratorInterface $urlGenerator,
private Security $security,
) {} ) {}
/** /**
@ -148,8 +150,10 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt
* @throws \Twig\Error\RuntimeError * @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError * @throws \Twig\Error\SyntaxError
*/ */
public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $canEdit = true, array $options = []): string public function renderButtonGroup(Environment $environment, StoredObject $document, ?string $title = null, bool $showEditButtons = true, array $options = []): string
{ {
$canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $document) && $showEditButtons;
$accessToken = $this->davTokenProvider->createToken( $accessToken = $this->davTokenProvider->createToken(
$document, $document,
$canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE $canEdit ? StoredObjectRoleEnum::EDIT : StoredObjectRoleEnum::SEE

View File

@ -122,7 +122,8 @@ class TempUrlOpenstackGeneratorTest extends TestCase
$signedUrl = new SignedUrl( $signedUrl = new SignedUrl(
'GET', 'GET',
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', 'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543') \DateTimeImmutable::createFromFormat('U', '1702043543'),
$objectName
); );
foreach ($baseUrls as $baseUrl) { foreach ($baseUrls as $baseUrl) {
@ -153,6 +154,7 @@ class TempUrlOpenstackGeneratorTest extends TestCase
$signedUrl = new SignedUrlPost( $signedUrl = new SignedUrlPost(
'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543', 'https://objectstore.example/v1/my_account/container/object?temp_url_sig=0aeef353a5f6e22d125c76c6ad8c644a59b222ba1b13eaeb56bf3d04e28b081d11dfcb36601ab3aa7b623d79e1ef03017071bbc842fb7b34afec2baff895bf80&temp_url_expires=1702043543',
\DateTimeImmutable::createFromFormat('U', '1702043543'), \DateTimeImmutable::createFromFormat('U', '1702043543'),
$objectName,
150, 150,
1, 1,
1800, 1800,

View File

@ -35,7 +35,7 @@ class AsyncUploadExtensionTest extends KernelTestCase
{ {
$generator = $this->prophesize(TempUrlGeneratorInterface::class); $generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any()) $generator->generate(Argument::in(['GET', 'POST']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'))); ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1]));
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class); $urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
$urlGenerator->generate('async_upload.generate_url', Argument::type('array')) $urlGenerator->generate('async_upload.generate_url', Argument::type('array'))

View File

@ -73,6 +73,7 @@ class AsyncUploadControllerTest extends TestCase
return new SignedUrlPost( return new SignedUrlPost(
'https://object.store.example', 'https://object.store.example',
new \DateTimeImmutable('1 hour'), new \DateTimeImmutable('1 hour'),
'abc',
150, 150,
1, 1,
1800, 1800,
@ -87,7 +88,8 @@ class AsyncUploadControllerTest extends TestCase
return new SignedUrl( return new SignedUrl(
$method, $method,
'https://object.store.example', 'https://object.store.example',
new \DateTimeImmutable('1 hour') new \DateTimeImmutable('1 hour'),
$object_name
); );
} }
}; };

View File

@ -23,6 +23,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase; use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Serializer;
@ -80,11 +81,15 @@ class StoredObjectTypeTest extends TypeTestCase
$urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL) $urlGenerator->generate('chill_docstore_dav_document_get', Argument::type('array'), UrlGeneratorInterface::ABSOLUTE_URL)
->willReturn('http://url/fake'); ->willReturn('http://url/fake');
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::cetera())->willReturn(true);
$serializer = new Serializer( $serializer = new Serializer(
[ [
new StoredObjectNormalizer( new StoredObjectNormalizer(
$jwtTokenProvider->reveal(), $jwtTokenProvider->reveal(),
$urlGenerator->reveal(), $urlGenerator->reveal(),
$security->reveal()
), ),
], ],
[ [

View File

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class AbstractStoredObjectVoterTest extends TestCase
{
private AssociatedEntityToStoredObjectInterface $repository;
private Security $security;
private WorkflowStoredObjectPermissionHelper $workflowDocumentService;
protected function setUp(): void
{
$this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class);
$this->security = $this->createMock(Security::class);
$this->workflowDocumentService = $this->createMock(WorkflowStoredObjectPermissionHelper::class);
}
private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter
{
// Anonymous class extending the abstract class
return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter {
public function __construct(
private bool $canBeAssociatedWithWorkflow,
private AssociatedEntityToStoredObjectInterface $repository,
Security $security,
?WorkflowStoredObjectPermissionHelper $workflowDocumentService = null
) {
parent::__construct($security, $workflowDocumentService);
}
protected function attributeToRole($attribute): string
{
return 'SOME_ROLE';
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return \stdClass::class;
}
protected function canBeAssociatedWithWorkflow(): bool
{
return $this->canBeAssociatedWithWorkflow;
}
};
}
private function setupMockObjects(): array
{
$user = new User();
$token = $this->createMock(TokenInterface::class);
$subject = new StoredObject();
$entity = new \stdClass();
return [$user, $token, $subject, $entity];
}
private function setupMocksForVoteOnAttribute(User $user, TokenInterface $token, bool $isGrantedForEntity, object $entity, bool $workflowAllowed): void
{
// Set up token to return user
$token->method('getUser')->willReturn($user);
// Mock the return of an AccompanyingCourseDocument by the repository
$this->repository->method('findAssociatedEntityToStoredObject')->willReturn($entity);
// Mock scenario where user is allowed to see_details of the AccompanyingCourseDocument
$this->security->method('isGranted')->willReturn($isGrantedForEntity);
// Mock case where user is blocked or not by workflow
$this->workflowDocumentService->method('notBlockedByWorkflow')->willReturn($workflowAllowed);
}
public function testSupportsOnAttribute(): void
{
list($user, $token, $subject, $entity) = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject));
}
public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void
{
list($user, $token, $subject, $entity) = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// The voteOnAttribute method should return True when workflow is allowed
self::assertTrue($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token));
}
public function testVoteOnAttributeNotAllowed(): void
{
list($user, $token, $subject, $entity) = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method where isGranted() returns false
$this->setupMocksForVoteOnAttribute($user, $token, false, $entity, true);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// The voteOnAttribute method should return True when workflow is allowed
self::assertFalse($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token));
}
public function testVoteOnAttributeAllowedWorkflowNotAllowed(): void
{
list($user, $token, $subject, $entity) = $this->setupMockObjects();
// Setup mocks for voteOnAttribute method
$this->setupMocksForVoteOnAttribute($user, $token, true, $entity, false);
$voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService);
// Test voteOnAttribute method
$attribute = StoredObjectRoleEnum::SEE;
$result = $voter->voteOnAttribute($attribute, $subject, $token);
// Assert that access is denied when workflow is not allowed
$this->assertFalse($result);
}
}

View File

@ -14,11 +14,13 @@ namespace Chill\DocStoreBundle\Tests\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Security\Guard\DavTokenAuthenticationEventSubscriber; use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\MainBundle\Entity\User;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Security;
/** /**
* @internal * @internal
@ -27,97 +29,93 @@ use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
*/ */
class StoredObjectVoterTest extends TestCase class StoredObjectVoterTest extends TestCase
{ {
use ProphecyTrait;
/** /**
* @dataProvider provideDataVote * @dataProvider provideDataVote
*/ */
public function testVote(TokenInterface $token, ?object $subject, string $attribute, mixed $expected): void public function testVote(array $storedObjectVotersDefinition, object $subject, string $attribute, bool $fallbackSecurityExpected, bool $securityIsGrantedResult, mixed $expected): void
{ {
$voter = new StoredObjectVoter(); $storedObjectVoters = array_map(fn (array $definition) => $this->buildStoredObjectVoter($definition[0], $definition[1], $definition[2]), $storedObjectVotersDefinition);
$token = new UsernamePasswordToken(new User(), 'chill_main', ['ROLE_USER']);
$security = $this->createMock(Security::class);
$security->expects($fallbackSecurityExpected ? $this->atLeastOnce() : $this->never())
->method('isGranted')
->with($this->logicalOr($this->identicalTo('ROLE_USER'), $this->identicalTo('ROLE_ADMIN')))
->willReturn($securityIsGrantedResult);
$voter = new StoredObjectVoter($security, $storedObjectVoters);
self::assertEquals($expected, $voter->vote($token, $subject, [$attribute])); self::assertEquals($expected, $voter->vote($token, $subject, [$attribute]));
} }
public function provideDataVote(): iterable private function buildStoredObjectVoter(bool $supportsIsCalled, bool $supports, bool $voteOnAttribute): StoredObjectVoterInterface
{
$storedObjectVoter = $this->createMock(StoredObjectVoterInterface::class);
$storedObjectVoter->expects($supportsIsCalled ? $this->once() : $this->never())->method('supports')
->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class))
->willReturn($supports);
$storedObjectVoter->expects($supportsIsCalled && $supports ? $this->once() : $this->never())->method('voteOnAttribute')
->with(self::isInstanceOf(StoredObjectRoleEnum::class), $this->isInstanceOf(StoredObject::class), $this->isInstanceOf(TokenInterface::class))
->willReturn($voteOnAttribute);
return $storedObjectVoter;
}
public static function provideDataVote(): iterable
{ {
yield [ yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, new StoredObject()), // we try with something else than a SToredObject, the voter should abstain
[[false, false, false]],
new \stdClass(), new \stdClass(),
'SOMETHING', 'SOMETHING',
false,
false,
VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_ABSTAIN,
]; ];
yield [ yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), // we try with an unsupported attribute, the voter must abstain
$so, [[false, false, false]],
new StoredObject(),
'SOMETHING', 'SOMETHING',
false,
false,
VoterInterface::ACCESS_ABSTAIN, VoterInterface::ACCESS_ABSTAIN,
]; ];
yield [ yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()), // happy scenario: there is a role voter
$so, [[true, true, true]],
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::EDIT, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::EDIT->value,
VoterInterface::ACCESS_DENIED,
];
yield [
$this->buildToken(StoredObjectRoleEnum::SEE, $so = new StoredObject()),
$so,
StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_GRANTED,
];
yield [
$this->buildToken(null, null),
new StoredObject(), new StoredObject(),
StoredObjectRoleEnum::SEE->value, StoredObjectRoleEnum::SEE->value,
VoterInterface::ACCESS_DENIED, false,
false,
VoterInterface::ACCESS_GRANTED,
]; ];
yield [ yield [
$this->buildToken(null, null), // there is a role voter, but not allowed to see the stored object
[[true, true, false]],
new StoredObject(), new StoredObject(),
StoredObjectRoleEnum::SEE->value, StoredObjectRoleEnum::SEE->value,
false,
false,
VoterInterface::ACCESS_DENIED, VoterInterface::ACCESS_DENIED,
]; ];
} yield [
// there is no role voter, fallback to security, which does not grant access
private function buildToken(?StoredObjectRoleEnum $storedObjectRoleEnum = null, ?StoredObject $storedObject = null): TokenInterface [[true, false, false]],
{ new StoredObject(),
$token = $this->prophesize(TokenInterface::class); StoredObjectRoleEnum::SEE->value,
true,
if (null !== $storedObjectRoleEnum) { false,
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(true); VoterInterface::ACCESS_DENIED,
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn($storedObjectRoleEnum); ];
} else { yield [
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willReturn(false); // there is no role voter, fallback to security, which does grant access
$token->getAttribute(DavTokenAuthenticationEventSubscriber::ACTIONS)->willThrow(new \InvalidArgumentException()); [[true, false, false]],
} new StoredObject(),
StoredObjectRoleEnum::SEE->value,
if (null !== $storedObject) { true,
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(true); true,
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn($storedObject->getUuid()->toString()); VoterInterface::ACCESS_GRANTED,
} else { ];
$token->hasAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willReturn(false);
$token->getAttribute(DavTokenAuthenticationEventSubscriber::STORED_OBJECT)->willThrow(new \InvalidArgumentException());
}
return $token->reveal();
} }
} }

View File

@ -38,7 +38,8 @@ class SignedUrlNormalizerTest extends KernelTestCase
$signedUrl = new SignedUrl( $signedUrl = new SignedUrl(
'GET', 'GET',
'https://object.store.example/container/object', 'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000') \DateTimeImmutable::createFromFormat('U', '1700000'),
'object'
); );
$actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]); $actual = self::$normalizer->normalize($signedUrl, 'json', [AbstractNormalizer::GROUPS => ['read']]);
@ -48,6 +49,7 @@ class SignedUrlNormalizerTest extends KernelTestCase
'method' => 'GET', 'method' => 'GET',
'expires' => 1_700_000, 'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object', 'url' => 'https://object.store.example/container/object',
'object_name' => 'object',
], ],
$actual $actual
); );

View File

@ -38,6 +38,7 @@ class SignedUrlPostNormalizerTest extends KernelTestCase
$signedUrl = new SignedUrlPost( $signedUrl = new SignedUrlPost(
'https://object.store.example/container/object', 'https://object.store.example/container/object',
\DateTimeImmutable::createFromFormat('U', '1700000'), \DateTimeImmutable::createFromFormat('U', '1700000'),
'abc',
15000, 15000,
1, 1,
180, 180,
@ -59,6 +60,7 @@ class SignedUrlPostNormalizerTest extends KernelTestCase
'method' => 'POST', 'method' => 'POST',
'expires' => 1_700_000, 'expires' => 1_700_000,
'url' => 'https://object.store.example/container/object', 'url' => 'https://object.store.example/container/object',
'object_name' => 'abc',
], ],
$actual $actual
); );

View File

@ -202,7 +202,8 @@ final class StoredObjectManagerTest extends TestCase
$response = new SignedUrl( $response = new SignedUrl(
'PUT', 'PUT',
'https://example.com/'.$storedObject->getFilename(), 'https://example.com/'.$storedObject->getFilename(),
new \DateTimeImmutable('1 hours') new \DateTimeImmutable('1 hours'),
$storedObject->getFilename()
); );
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);

View File

@ -43,7 +43,7 @@ class AsyncFileExistsValidatorTest extends ConstraintValidatorTestCase
$generator = $this->prophesize(TempUrlGeneratorInterface::class); $generator = $this->prophesize(TempUrlGeneratorInterface::class);
$generator->generate(Argument::in(['GET', 'HEAD']), Argument::type('string'), Argument::any()) $generator->generate(Argument::in(['GET', 'HEAD']), Argument::type('string'), Argument::any())
->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'))); ->will(fn (array $args): SignedUrl => new SignedUrl($args[0], 'https://object.store.example/container/'.$args[1], new \DateTimeImmutable('1 hours'), $args[1]));
return new AsyncFileExistsValidator($generator->reveal(), $client); return new AsyncFileExistsValidator($generator->reveal(), $client);
} }

View File

@ -12,31 +12,25 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Workflow; namespace Chill\DocStoreBundle\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument; use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\DocStoreBundle\Repository\AccompanyingCourseDocumentRepository;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter; use Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument> * @implements EntityWorkflowWithStoredObjectHandlerInterface<AccompanyingCourseDocument>
*/ */
class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface readonly class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface
{ {
private readonly EntityRepository $repository;
/**
* TODO: injecter le repository directement.
*/
public function __construct( public function __construct(
EntityManagerInterface $em, private TranslatorInterface $translator,
private readonly TranslatorInterface $translator private EntityWorkflowRepository $workflowRepository,
) { private AccompanyingCourseDocumentRepository $repository
$this->repository = $em->getRepository(AccompanyingCourseDocument::class); ) {}
}
public function getDeletionRoles(): array public function getDeletionRoles(): array
{ {
@ -128,4 +122,18 @@ class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowWithSto
{ {
return $this->getRelatedEntity($entityWorkflow)?->getObject(); return $this->getRelatedEntity($entityWorkflow)?->getObject();
} }
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return false;
}
public function findByRelatedEntity(object $object): array
{
if (!$object instanceof AccompanyingCourseDocument) {
return [];
}
return $this->workflowRepository->findByRelatedEntity(AccompanyingCourseDocument::class, $object->getId());
}
} }

View File

@ -0,0 +1,13 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter:
arguments:
$storedObjectVoters: !tagged_iterator stored_object_voter
tags:
- { name: security.voter }
Chill\DocStoreBundle\Security\Authorization\AccompanyingCourseDocumentVoter:
tags:
- { name: security.voter }

View File

@ -31,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Class Event. * Class Event.
*/ */
#[ORM\Entity(repositoryClass: \Chill\EventBundle\Repository\EventRepository::class)] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'chill_event_event')] #[ORM\Table(name: 'chill_event_event')]
class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface class Event implements HasCenterInterface, HasScopeInterface, TrackCreationInterface, TrackUpdateInterface

View File

@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\EventBundle\Form\ChoiceLoader; namespace Chill\EventBundle\Form\ChoiceLoader;
use Chill\EventBundle\Entity\Event; use Chill\EventBundle\Entity\Event;
use Doctrine\ORM\EntityRepository; use Chill\EventBundle\Repository\EventRepository;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface; use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
@ -26,9 +26,6 @@ class EventChoiceLoader implements ChoiceLoaderInterface
*/ */
protected $centers = []; protected $centers = [];
/**
* @var EntityRepository
*/
protected $eventRepository; protected $eventRepository;
/** /**
@ -40,7 +37,7 @@ class EventChoiceLoader implements ChoiceLoaderInterface
* EventChoiceLoader constructor. * EventChoiceLoader constructor.
*/ */
public function __construct( public function __construct(
EntityRepository $eventRepository, EventRepository $eventRepository,
?array $centers = null ?array $centers = null
) { ) {
$this->eventRepository = $eventRepository; $this->eventRepository = $eventRepository;

View File

@ -11,17 +11,65 @@ declare(strict_types=1);
namespace Chill\EventBundle\Repository; namespace Chill\EventBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\EventBundle\Entity\Event; use Chill\EventBundle\Entity\Event;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
/** /**
* Class EventRepository. * Class EventRepository.
*/ */
class EventRepository extends ServiceEntityRepository class EventRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{ {
public function __construct(ManagerRegistry $registry) private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{ {
parent::__construct($registry, Event::class); $this->repository = $entityManager->getRepository(Event::class);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object
{
$qb = $this->createQueryBuilder('e');
$query = $qb
->join('e.documents', 'ed')
->where('ed.id = :storedObjectId')
->setParameter('storedObjectId', $storedObject->getId())
->getQuery();
return $query->getOneOrNullResult();
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return Event::class;
} }
} }

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\EventBundle\Security\Authorization;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\EventBundle\Entity\Event;
use Chill\EventBundle\Repository\EventRepository;
use Symfony\Component\Security\Core\Security;
class EventStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly EventRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return Event::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::EDIT => EventVoter::UPDATE,
StoredObjectRoleEnum::SEE => EventVoter::SEE_DETAILS,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return false;
}
}

View File

@ -96,7 +96,7 @@ class SearchController extends AbstractController
return $this->render('@ChillMain/Search/choose_list.html.twig'); return $this->render('@ChillMain/Search/choose_list.html.twig');
} }
#[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json'], defaults: ['_format' => 'html'])] #[\Symfony\Component\Routing\Annotation\Route(path: '/{_locale}/search.{_format}', name: 'chill_main_search', requirements: ['_format' => 'html|json', '_locale' => '[a-z]{1,3}'], defaults: ['_format' => 'html'])]
public function searchAction(Request $request, mixed $_format) public function searchAction(Request $request, mixed $_format)
{ {
$pattern = trim((string) $request->query->get('q', '')); $pattern = trim((string) $request->query->get('q', ''));

View File

@ -99,6 +99,24 @@ class EntityWorkflowRepository implements ObjectRepository
return $this->repository->findAll(); return $this->repository->findAll();
} }
/**
* @return list<EntityWorkflow>
*/
public function findByRelatedEntity($entityClass, $relatedEntityId): array
{
$qb = $this->repository->createQueryBuilder('w');
$query = $qb->where(
$qb->expr()->andX(
$qb->expr()->eq('w.relatedEntityClass', ':entity_class'),
$qb->expr()->eq('w.relatedEntityId', ':entity_id'),
)
)->setParameter('entity_class', $entityClass)
->setParameter('entity_id', $relatedEntityId);
return $query->getQuery()->getResult();
}
/** /**
* @param mixed|null $limit * @param mixed|null $limit
* @param mixed|null $offset * @param mixed|null $offset

View File

@ -55,4 +55,11 @@ interface EntityWorkflowHandlerInterface
public function isObjectSupported(object $object): bool; public function isObjectSupported(object $object): bool;
public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool; public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool;
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool;
/**
* @return list<EntityWorkflow>
*/
public function findByRelatedEntity(object $object): array;
} }

View File

@ -49,4 +49,18 @@ class EntityWorkflowManager
return null; return null;
} }
/**
* @return list<EntityWorkflow>
*/
public function findByRelatedEntity(object $object): array
{
foreach ($this->handlers as $handler) {
if ([] !== $workflows = $handler->findByRelatedEntity($object)) {
return $workflows;
}
}
return [];
}
} }

View File

@ -11,14 +11,17 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod; namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
class AccompanyingPeriodWorkEvaluationDocumentRepository implements ObjectRepository readonly class AccompanyingPeriodWorkEvaluationDocumentRepository implements ObjectRepository, AssociatedEntityToStoredObjectInterface
{ {
private readonly EntityRepository $repository; private EntityRepository $repository;
public function __construct(EntityManagerInterface $em) public function __construct(EntityManagerInterface $em)
{ {
@ -58,4 +61,18 @@ class AccompanyingPeriodWorkEvaluationDocumentRepository implements ObjectReposi
{ {
return AccompanyingPeriodWorkEvaluationDocument::class; return AccompanyingPeriodWorkEvaluationDocument::class;
} }
/**
* @throws NonUniqueResultException
*/
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?AccompanyingPeriodWorkEvaluationDocument
{
$qb = $this->repository->createQueryBuilder('acpwed');
$query = $qb
->where('acpwed.storedObject = :storedObject')
->setParameter('storedObject', $storedObject)
->getQuery();
return $query->getOneOrNullResult();
}
} }

View File

@ -24,13 +24,14 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class AccompanyingPeriodWorkEvaluationDocumentVoter extends Voter class AccompanyingPeriodWorkEvaluationDocumentVoter extends Voter
{ {
final public const SEE = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_DOCUMENT_SHOW'; final public const SEE = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_DOCUMENT_SHOW';
final public const SEE_AND_EDIT = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_DOCUMENT_EDIT';
public function __construct(private readonly AccessDecisionManagerInterface $accessDecisionManager) {} public function __construct(private readonly AccessDecisionManagerInterface $accessDecisionManager) {}
protected function supports($attribute, $subject) public function supports($attribute, $subject): bool
{ {
return $subject instanceof AccompanyingPeriodWorkEvaluationDocument return $subject instanceof AccompanyingPeriodWorkEvaluationDocument
&& self::SEE === $attribute; && (self::SEE === $attribute || self::SEE_AND_EDIT === $attribute);
} }
/** /**
@ -39,7 +40,7 @@ class AccompanyingPeriodWorkEvaluationDocumentVoter extends Voter
* *
* @return bool|void * @return bool|void
*/ */
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool public function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{ {
return match ($attribute) { return match ($attribute) {
self::SEE => $this->accessDecisionManager->decide( self::SEE => $this->accessDecisionManager->decide(
@ -47,6 +48,11 @@ class AccompanyingPeriodWorkEvaluationDocumentVoter extends Voter
[AccompanyingPeriodWorkEvaluationVoter::SEE], [AccompanyingPeriodWorkEvaluationVoter::SEE],
$subject->getAccompanyingPeriodWorkEvaluation() $subject->getAccompanyingPeriodWorkEvaluation()
), ),
self::SEE_AND_EDIT => $this->accessDecisionManager->decide(
$token,
[AccompanyingPeriodWorkEvaluationVoter::SEE_AND_EDIT],
$subject->getAccompanyingPeriodWorkEvaluation()
),
default => throw new \UnexpectedValueException("The attribute {$attribute} is not supported"), default => throw new \UnexpectedValueException("The attribute {$attribute} is not supported"),
}; };
} }

View File

@ -21,11 +21,14 @@ class AccompanyingPeriodWorkEvaluationVoter extends Voter implements ChillVoterI
{ {
final public const ALL = [ final public const ALL = [
self::SEE, self::SEE,
self::SEE_AND_EDIT,
self::STATS, self::STATS,
]; ];
final public const SEE = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_SHOW'; final public const SEE = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_SHOW';
final public const SEE_AND_EDIT = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_EDIT';
final public const STATS = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_STATS'; final public const STATS = 'CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_STATS';
public function __construct(private readonly Security $security) {} public function __construct(private readonly Security $security) {}
@ -45,6 +48,7 @@ class AccompanyingPeriodWorkEvaluationVoter extends Voter implements ChillVoterI
return match ($attribute) { return match ($attribute) {
self::STATS => $this->security->isGranted(AccompanyingPeriodVoter::STATS, $subject), self::STATS => $this->security->isGranted(AccompanyingPeriodVoter::STATS, $subject),
self::SEE => $this->security->isGranted(AccompanyingPeriodWorkVoter::SEE, $subject->getAccompanyingPeriodWork()), self::SEE => $this->security->isGranted(AccompanyingPeriodWorkVoter::SEE, $subject->getAccompanyingPeriodWork()),
self::SEE_AND_EDIT => $this->security->isGranted(AccompanyingPeriodWorkVoter::UPDATE, $subject->getAccompanyingPeriodWork()),
default => throw new \UnexpectedValueException("attribute {$attribute} is not supported"), default => throw new \UnexpectedValueException("attribute {$attribute} is not supported"),
}; };
} }

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoter\AbstractStoredObjectVoter;
use Chill\DocStoreBundle\Service\WorkflowStoredObjectPermissionHelper;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocumentRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkEvaluationDocumentVoter;
use Symfony\Component\Security\Core\Security;
class AccompanyingPeriodWorkEvaluationDocumentStoredObjectVoter extends AbstractStoredObjectVoter
{
public function __construct(
private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository,
Security $security,
WorkflowStoredObjectPermissionHelper $workflowDocumentService
) {
parent::__construct($security, $workflowDocumentService);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return AccompanyingPeriodWorkEvaluationDocument::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return match ($attribute) {
StoredObjectRoleEnum::SEE => AccompanyingPeriodWorkEvaluationDocumentVoter::SEE,
StoredObjectRoleEnum::EDIT => AccompanyingPeriodWorkEvaluationDocumentVoter::SEE_AND_EDIT,
};
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\PersonBundle\Workflow;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowWithStoredObjectHandlerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
@ -25,7 +26,12 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/ */
class AccompanyingPeriodWorkEvaluationDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface class AccompanyingPeriodWorkEvaluationDocumentWorkflowHandler implements EntityWorkflowWithStoredObjectHandlerInterface
{ {
public function __construct(private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository, private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly TranslatorInterface $translator) {} public function __construct(
private readonly AccompanyingPeriodWorkEvaluationDocumentRepository $repository,
private readonly EntityWorkflowRepository $workflowRepository,
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly TranslatorInterface $translator
) {}
public function getDeletionRoles(): array public function getDeletionRoles(): array
{ {
@ -130,4 +136,18 @@ class AccompanyingPeriodWorkEvaluationDocumentWorkflowHandler implements EntityW
{ {
return $this->getRelatedEntity($entityWorkflow)?->getStoredObject(); return $this->getRelatedEntity($entityWorkflow)?->getStoredObject();
} }
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return false;
}
public function findByRelatedEntity(object $object): array
{
if (!$object instanceof AccompanyingPeriodWorkEvaluationDocument) {
return [];
}
return $this->workflowRepository->findByRelatedEntity(AccompanyingPeriodWorkEvaluationDocument::class, $object->getId());
}
} }

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Workflow; namespace Chill\PersonBundle\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
@ -27,6 +28,7 @@ readonly class AccompanyingPeriodWorkEvaluationWorkflowHandler implements Entity
{ {
public function __construct( public function __construct(
private AccompanyingPeriodWorkEvaluationRepository $repository, private AccompanyingPeriodWorkEvaluationRepository $repository,
private EntityWorkflowRepository $workflowRepository,
private TranslatableStringHelperInterface $translatableStringHelper, private TranslatableStringHelperInterface $translatableStringHelper,
private TranslatorInterface $translator private TranslatorInterface $translator
) {} ) {}
@ -113,4 +115,18 @@ readonly class AccompanyingPeriodWorkEvaluationWorkflowHandler implements Entity
{ {
return AccompanyingPeriodWorkEvaluation::class === $entityWorkflow->getRelatedEntityClass(); return AccompanyingPeriodWorkEvaluation::class === $entityWorkflow->getRelatedEntityClass();
} }
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return false;
}
public function findByRelatedEntity(object $object): array
{
if (!$object instanceof AccompanyingPeriodWorkEvaluation) {
return [];
}
return $this->workflowRepository->findByRelatedEntity(AccompanyingPeriodWorkEvaluation::class, $object->getId());
}
} }

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Workflow; namespace Chill\PersonBundle\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
@ -28,6 +29,7 @@ readonly class AccompanyingPeriodWorkWorkflowHandler implements EntityWorkflowHa
{ {
public function __construct( public function __construct(
private AccompanyingPeriodWorkRepository $repository, private AccompanyingPeriodWorkRepository $repository,
private EntityWorkflowRepository $workflowRepository,
private TranslatableStringHelperInterface $translatableStringHelper, private TranslatableStringHelperInterface $translatableStringHelper,
private TranslatorInterface $translator private TranslatorInterface $translator
) {} ) {}
@ -120,4 +122,18 @@ readonly class AccompanyingPeriodWorkWorkflowHandler implements EntityWorkflowHa
{ {
return AccompanyingPeriodWork::class === $entityWorkflow->getRelatedEntityClass(); return AccompanyingPeriodWork::class === $entityWorkflow->getRelatedEntityClass();
} }
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return false;
}
public function findByRelatedEntity(object $object): array
{
if (!$object instanceof AccompanyingPeriodWork) {
return [];
}
return $this->workflowRepository->findByRelatedEntity(AccompanyingPeriodWork::class, $object->getId());
}
} }

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\WopiBundle\Service\Wopi; namespace Chill\WopiBundle\Service\Wopi;
use ChampsLibres\WopiLib\Contract\Entity\Document; use ChampsLibres\WopiLib\Contract\Entity\Document;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
@ -19,13 +21,18 @@ use Symfony\Component\Security\Core\Security;
class AuthorizationManager implements \ChampsLibres\WopiBundle\Contracts\AuthorizationManagerInterface class AuthorizationManager implements \ChampsLibres\WopiBundle\Contracts\AuthorizationManagerInterface
{ {
public function __construct(private readonly JWTTokenManagerInterface $tokenManager, private readonly Security $security) {} public function __construct(private readonly JWTTokenManagerInterface $tokenManager, private readonly Security $security, private readonly StoredObjectRepository $storedObjectRepository) {}
public function isRestrictedWebViewOnly(string $accessToken, Document $document, RequestInterface $request): bool public function isRestrictedWebViewOnly(string $accessToken, Document $document, RequestInterface $request): bool
{ {
return false; return false;
} }
public function getRelatedStoredObject(Document $document)
{
return $this->storedObjectRepository->findOneBy(['uuid' => $document->getWopiDocId()]);
}
public function isTokenValid(string $accessToken, Document $document, RequestInterface $request): bool public function isTokenValid(string $accessToken, Document $document, RequestInterface $request): bool
{ {
$metadata = $this->tokenManager->parse($accessToken); $metadata = $this->tokenManager->parse($accessToken);
@ -60,12 +67,23 @@ class AuthorizationManager implements \ChampsLibres\WopiBundle\Contracts\Authori
public function userCanPresent(string $accessToken, Document $document, RequestInterface $request): bool public function userCanPresent(string $accessToken, Document $document, RequestInterface $request): bool
{ {
return $this->isTokenValid($accessToken, $document, $request); $storedObject = $this->getRelatedStoredObject($document);
if ($this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
return $this->isTokenValid($accessToken, $document, $request);
}
return false;
} }
public function userCanRead(string $accessToken, Document $document, RequestInterface $request): bool public function userCanRead(string $accessToken, Document $document, RequestInterface $request): bool
{ {
return $this->isTokenValid($accessToken, $document, $request); $storedObject = $this->getRelatedStoredObject($document);
if ($this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
return $this->isTokenValid($accessToken, $document, $request);
}
return false;
} }
public function userCanRename(string $accessToken, Document $document, RequestInterface $request): bool public function userCanRename(string $accessToken, Document $document, RequestInterface $request): bool
@ -75,6 +93,12 @@ class AuthorizationManager implements \ChampsLibres\WopiBundle\Contracts\Authori
public function userCanWrite(string $accessToken, Document $document, RequestInterface $request): bool public function userCanWrite(string $accessToken, Document $document, RequestInterface $request): bool
{ {
return $this->isTokenValid($accessToken, $document, $request); $storedObject = $this->getRelatedStoredObject($document);
if ($this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
return $this->isTokenValid($accessToken, $document, $request);
}
return false;
} }
} }