From 829fb669febe5b020e0a435e659835ec3416d78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 13 Nov 2024 22:41:30 +0100 Subject: [PATCH] Update Workflow Permission Handling Refactor the `WorkflowRelatedEntityPermissionHelper` to enhance permission checks for workflow-related entities. This includes updating methods, improving test coverage, and incorporating `MockClock` for date-sensitive operations. --- .../AbstractStoredObjectVoter.php | 27 ++- .../AbstractStoredObjectVoterTest.php | 172 +++++++------- ...kflowRelatedEntityPermissionHelperTest.php | 222 +++++++++++------- .../WorkflowRelatedEntityPermissionHelper.php | 163 ++++++++----- 4 files changed, 335 insertions(+), 249 deletions(-) diff --git a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php index 1b9378e72..161360c52 100644 --- a/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php +++ b/src/Bundle/ChillDocStoreBundle/Security/Authorization/StoredObjectVoter/AbstractStoredObjectVoter.php @@ -46,24 +46,27 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool { - // Retrieve the related accompanying course document + // Retrieve the related entity $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); - // Determine the attribute to pass to AccompanyingCourseDocumentVoter + // Determine the attribute to pass to the voter for argument $voterAttribute = $this->attributeToRole($attribute); - if (false === $this->security->isGranted($voterAttribute, $entity)) { - return false; + $regularPermission = $this->security->isGranted($voterAttribute, $entity); + + if (!$this->canBeAssociatedWithWorkflow()) { + return $regularPermission; } - if (StoredObjectRoleEnum::SEE !== $attribute && $this->canBeAssociatedWithWorkflow()) { - if (null === $this->workflowDocumentService) { - throw new \LogicException('Provide a workflow document service'); - } + $workflowPermission = match ($attribute) { + StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($entity), + StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($entity), + }; - return $this->workflowDocumentService->notBlockedByWorkflow($entity); - } - - return true; + return match ($workflowPermission) { + WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, + WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, + WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, + }; } } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php index 6fbb9c2e4..fc6099557 100644 --- a/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php +++ b/src/Bundle/ChillDocStoreBundle/Tests/Security/Authorization/AbstractStoredObjectVoterTest.php @@ -15,10 +15,11 @@ 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\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use PHPUnit\Framework\TestCase; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\Security; /** @@ -28,19 +29,14 @@ use Symfony\Component\Security\Core\Security; */ class AbstractStoredObjectVoterTest extends TestCase { - private AssociatedEntityToStoredObjectInterface $repository; - private Security $security; - private WorkflowRelatedEntityPermissionHelper $workflowDocumentService; + use ProphecyTrait; - protected function setUp(): void - { - $this->repository = $this->createMock(AssociatedEntityToStoredObjectInterface::class); - $this->security = $this->createMock(Security::class); - $this->workflowDocumentService = $this->createMock(WorkflowRelatedEntityPermissionHelper::class); - } - - private function buildStoredObjectVoter(bool $canBeAssociatedWithWorkflow, AssociatedEntityToStoredObjectInterface $repository, Security $security, ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null): AbstractStoredObjectVoter - { + private function buildStoredObjectVoter( + bool $canBeAssociatedWithWorkflow, + AssociatedEntityToStoredObjectInterface $repository, + Security $security, + ?WorkflowRelatedEntityPermissionHelper $workflowDocumentService = null, + ): AbstractStoredObjectVoter { // Anonymous class extending the abstract class return new class ($canBeAssociatedWithWorkflow, $repository, $security, $workflowDocumentService) extends AbstractStoredObjectVoter { public function __construct( @@ -74,95 +70,89 @@ class AbstractStoredObjectVoterTest extends TestCase }; } - 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 { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(new \stdClass()), $this->prophesize(Security::class)->reveal(), null); - // 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, new StoredObject())); - self::assertTrue($voter->supports(StoredObjectRoleEnum::SEE, $subject)); + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(new User()), $this->prophesize(Security::class)->reveal(), null); + + self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject())); + + $voter = $this->buildStoredObjectVoter(false, new DummyRepository(null), $this->prophesize(Security::class)->reveal(), null); + + self::assertFalse($voter->supports(StoredObjectRoleEnum::SEE, new StoredObject())); } - public function testVoteOnAttributeAllowedAndWorkflowAllowed(): void - { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + /** + * @dataProvider dataProviderVoteOnAttribute + */ + public function testVoteOnAttribute( + StoredObjectRoleEnum $attribute, + bool $expected, + bool $canBeAssociatedWithWorkflow, + bool $isGrantedRegularPermission, + ?string $isGrantedWorkflowPermissionRead, + ?string $isGrantedWorkflowPermissionWrite, + string $message, + ): void { + $storedObject = new StoredObject(); + $dummyRepository = new DummyRepository($related = new \stdClass()); + $token = new UsernamePasswordToken(new User(), 'dummy'); - // Setup mocks for voteOnAttribute method - $this->setupMocksForVoteOnAttribute($user, $token, true, $entity, true); - $voter = $this->buildStoredObjectVoter(true, $this->repository, $this->security, $this->workflowDocumentService); + $security = $this->prophesize(Security::class); + $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); - // The voteOnAttribute method should return True when workflow is allowed - self::assertTrue($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); + $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); + if (null !== $isGrantedWorkflowPermissionRead) { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) + ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); + } else { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)->shouldNotBeCalled(); + } + + if (null !== $isGrantedWorkflowPermissionWrite) { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related) + ->willReturn($isGrantedWorkflowPermissionWrite)->shouldBeCalled(); + } else { + $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)->shouldNotBeCalled(); + } + + $voter = $this->buildStoredObjectVoter($canBeAssociatedWithWorkflow, $dummyRepository, $security->reveal(), $workflowRelatedEntityPermissionHelper->reveal()); + self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); } - public function testVoteOnAttributeNotAllowed(): void + public static function dataProviderVoteOnAttribute(): iterable { - [$user, $token, $subject, $entity] = $this->setupMockObjects(); + // not associated on a workflow + yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; + yield [StoredObjectRoleEnum::SEE, false, false, false, null, null, 'not associated on a workflow, denied by regular access, must not rely on helper']; - // 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); + // associated on a workflow, read operation + yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, true, true, true, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, read by regular, abstain by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, true, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, read by regular, force grant by workflow, ask for read, should be denied']; + yield [StoredObjectRoleEnum::SEE, true, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::ABSTAIN, null, 'associated on a workflow, denied read by regular, abstain by workflow, ask for read, should be granted']; + yield [StoredObjectRoleEnum::SEE, false, true, false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, null, 'associated on a workflow, denied read by regular, force grant by workflow, ask for read, should be denied']; - // The voteOnAttribute method should return True when workflow is allowed - self::assertFalse($voter->voteOnAttribute(StoredObjectRoleEnum::SEE, $subject, $token)); - } - - public function testVoteOnAttributeAllowedWorkflowNotAllowed(): void - { - [$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::EDIT; - $result = $voter->voteOnAttribute($attribute, $subject, $token); - - // Assert that access is denied when workflow is not allowed - $this->assertFalse($result); - } - - public function testVoteOnAttributeAllowedWorkflowAllowedToSeeDocument(): void - { - [$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->assertTrue($result); + // association on a workflow, write operation + yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, true, true, true, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, write by regular, abstain by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, true, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, write by regular, force grant by workflow, ask for write, should be denied']; + yield [StoredObjectRoleEnum::EDIT, true, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::ABSTAIN, 'associated on a workflow, denied write by regular, abstain by workflow, ask for write, should be granted']; + yield [StoredObjectRoleEnum::EDIT, false, true, false, null, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, 'associated on a workflow, denied write by regular, force grant by workflow, ask for write, should be denied']; + } +} + +class DummyRepository implements AssociatedEntityToStoredObjectInterface +{ + public function __construct(private readonly ?object $relatedEntity) {} + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?object + { + return $this->relatedEntity; } } diff --git a/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php index 3e4857d57..fc06a533f 100644 --- a/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Workflow/Helper/WorkflowRelatedEntityPermissionHelperTest.php @@ -15,7 +15,6 @@ use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; -use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore; use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO; @@ -23,6 +22,7 @@ use Chill\PersonBundle\Entity\Person; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\DefinitionBuilder; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; @@ -41,67 +41,85 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase use ProphecyTrait; /** - * @dataProvider provideDataNotBlockByWorkflow + * @dataProvider provideDataAllowedByWorkflowReadOperation + * + * @param list $entityWorkflows */ - public function testNotBlockByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void - { + public function testAllowedByWorkflowRead( + array $entityWorkflows, + User $user, + string $expected, + ?\DateTimeImmutable $atDate, + string $message, + ): void { // all entities must have this workflow name, so we are ok to set it here - $entityWorkflow->setWorkflowName('dummy'); - $object = new \stdClass(); - $helper = $this->buildHelper($object, $entityWorkflow, $user); + foreach ($entityWorkflows as $entityWorkflow) { + $entityWorkflow->setWorkflowName('dummy'); + } + $helper = $this->buildHelper($entityWorkflows, $user, $atDate); - self::assertEquals($expected, $helper->notBlockedByWorkflow($entityWorkflow), $message); + self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new \stdClass()), $message); } /** - * @dataProvider provideDataAllowedByWorkflow + * @dataProvider provideDataAllowedByWorkflowWriteOperation + * + * @param list $entityWorkflows */ - public function testAllowedByWorkflow(EntityWorkflow $entityWorkflow, User $user, bool $expected, string $message): void - { + public function testAllowedByWorkflowWrite( + array $entityWorkflows, + User $user, + string $expected, + ?\DateTimeImmutable $atDate, + string $message, + ): void { // all entities must have this workflow name, so we are ok to set it here - $entityWorkflow->setWorkflowName('dummy'); - $object = new \stdClass(); - $helper = $this->buildHelper($object, $entityWorkflow, $user); + foreach ($entityWorkflows as $entityWorkflow) { + $entityWorkflow->setWorkflowName('dummy'); + } + $helper = $this->buildHelper($entityWorkflows, $user, $atDate); - self::assertEquals($expected, $helper->isAllowedByWorkflow($entityWorkflow), $message); + self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new \stdClass()), $message); } public function testNoWorkflow(): void { - $object = new \stdClass(); - $helper = $this->buildHelper($object, null, $user = new User()); - self::assertTrue($helper->notBlockedByWorkflow($object), "the user is not blocked by the user, as there aren't any user inside"); + $helper = $this->buildHelper([], new User(), null); + + self::assertEquals(WorkflowRelatedEntityPermissionHelper::ABSTAIN, $helper->isAllowedByWorkflowForWriteOperation(new \stdClass())); + self::assertEquals(WorkflowRelatedEntityPermissionHelper::ABSTAIN, $helper->isAllowedByWorkflowForReadOperation(new \stdClass())); } - private function buildHelper(object $relatedEntity, ?EntityWorkflow $entityWorkflow, User $user): WorkflowRelatedEntityPermissionHelper + /** + * @param list $entityWorkflows + */ + private function buildHelper(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper { $security = $this->prophesize(Security::class); $security->getUser()->willReturn($user); $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); - if (null !== $entityWorkflow) { - $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([$entityWorkflow]); - } else { - $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn([]); - } + $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); - return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry()); + return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); } - public static function provideDataAllowedByWorkflow(): iterable + public static function provideDataAllowedByWorkflowReadOperation(): iterable { $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, new User(), false, 'not allowed because the user is not present as a dest user']; + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain because the user is not present as a dest user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user is a current user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user is a current user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -112,7 +130,68 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $dto->futureDestUsers[] = new User(); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user was a previous user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user was a previous user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because there is a signature for person']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable('2024-01-01T12:00:00')); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable('2024-01-10T12:00:00'), + 'abstain because there is a signature for person, already signed, and for a long time ago']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED) + ->setStateDate(new \DateTimeImmutable('2024-01-01T12:00:00')); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, + new \DateTimeImmutable('2024-01-01T12:30:00'), + 'force grant because there is a signature for person, already signed, a short time ago']; + } + + public static function provideDataAllowedByWorkflowWriteOperation(): iterable + { + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain because the user is not present as a dest user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user is a current user']; + + $entityWorkflow = new EntityWorkflow(); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = $user = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futureDestUsers[] = new User(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant because the user was a previous user']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -122,7 +201,8 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, false, 'not allowed because: user was a previous user, but it is finalized positive']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), + 'force denied: user was a previous user, but it is finalized positive']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); @@ -132,80 +212,48 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, true, 'allowed: user was a previous user, it is finalized, but finalized negative']; - - } - - public static function provideDataNotBlockByWorkflow(): iterable - { - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable()); - - yield [$entityWorkflow, new User(), false, 'blocked because the user is not present as a dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain: user was a previous user, it is finalized, but finalized negative']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); - yield [$entityWorkflow, $user, true, 'allowed because the user is present as a dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), + 'force denied because there is a signature']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); - yield [$entityWorkflow, $user, true, 'allowed because the user is present as a **previous** dest user']; + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), + 'force grant: there is a signature, but still pending']; $entityWorkflow = new EntityWorkflow(); $dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), $user); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $dto->futurePersonSignatures[] = new Person(); + $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); + $signature = $entityWorkflow->getCurrentStep()->getSignatures()->first(); + $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable()); + $dto = new WorkflowTransitionContextDTO($entityWorkflow); + $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User()); $entityWorkflow->getCurrentStep()->setIsFinal(true); - yield [$entityWorkflow, $user, false, 'blocked because the step is final, and final positive']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user); - $entityWorkflow->getCurrentStep()->setIsFinal(true); - - yield [$entityWorkflow, $user, true, 'allowed because the step is final, and final negative']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - new EntityWorkflowStepSignature($step, new Person()); - - yield [$entityWorkflow, $user, true, 'allow, a signature is present but still pending']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - $signature = new EntityWorkflowStepSignature($step, new Person()); - $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); - - yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed']; - - $entityWorkflow = new EntityWorkflow(); - $dto = new WorkflowTransitionContextDTO($entityWorkflow); - $dto->futureDestUsers[] = $user = new User(); - $entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), $user); - $step = $entityWorkflow->getCurrentStep(); - $signature = new EntityWorkflowStepSignature($step, new Person()); - $signature->setState(EntityWorkflowSignatureStateEnum::SIGNED); - - yield [$entityWorkflow, $user, false, 'blocked, a signature is present and signed, although the workflow is final negative']; - + yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), + 'abstain: there is a signature on a canceled workflow']; } private static function buildRegistry(): Registry diff --git a/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php index 472ab93ee..cd890154e 100644 --- a/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php +++ b/src/Bundle/ChillMainBundle/Workflow/Helper/WorkflowRelatedEntityPermissionHelper.php @@ -11,29 +11,100 @@ declare(strict_types=1); namespace Chill\MainBundle\Workflow\Helper; +use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Workflow\EntityWorkflowManager; +use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Security\Core\Security; use Symfony\Component\Workflow\Registry; /** - * Check if an object, associated with a workflow, is blocked, or not, by this workflow. + * Helper to give supplementary permissions to a related entity. + * + * If a related entity is associated within a workflow, the logic of the workflow can give more permissions, or + * remove some permissions. + * + * The methods of this helper return either: + * + * - FORCE_GRANT, which means that a permission can be given, even if it would be denied when the related + * entity is not associated with a workflow; + * - FORCE_DENIED, which means that a permission should be denied, even if it would be granted when the related entity + * is not associated with a workflow + * - ABSTAIN, if there is no workflow logic to add or remove permission + * + * For read operations: + * + * - if the user is involved in the workflow (is part of the current step, of a step before), the user is granted read + * operation; + * - if there is a pending signature for a person, the workflow grant access to the related entity; + * - if there a signature applyied in less than 12 hours, the workflow grant access to the related entity. This allow to + * show the related entity to the person during this time frame. + * + * + * For write operation: + * + * - if the workflow is finalized "positive" (means "not canceled"), the workflow denys write operations; + * - if there isn't any finalized "positive" workflow, and if there is a signature appliyed for a running workflow (not finalized nor canceled), + * the workflow denys write operations; + * - if there is no case above and the user is involved in the workflow (is part of the current step, of a step before), the user is granted; */ class WorkflowRelatedEntityPermissionHelper { + public const FORCE_GRANT = 'FORCE_GRANT'; + public const FORCE_DENIED = 'FORCE_DENIED'; + public const ABSTAIN = 'ABSTAIN'; + public function __construct( private readonly Security $security, private readonly EntityWorkflowManager $entityWorkflowManager, private readonly Registry $registry, + private readonly ClockInterface $clock, ) {} - public function isAllowedByWorkflow(object $entity): bool + /** + * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' + */ + public function isAllowedByWorkflowForReadOperation(object $entity): string { $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); - $currentUser = $this->security->getUser(); + + if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) { + return self::FORCE_GRANT; + } + + // give a view permission if there is a Person signature pending, or in the 12 hours following + // the signature last state + foreach ($entityWorkflows as $workflow) { + foreach ($workflow->getCurrentStep()->getSignatures() as $signature) { + if ('person' === $signature->getSignerKind()) { + if (EntityWorkflowSignatureStateEnum::PENDING === $signature->getState()) { + return self::FORCE_GRANT; + } + $signatureDate = $signature->getStateDate(); + $visibleUntil = $signatureDate->add(new \DateInterval('PT12H')); + + if ($visibleUntil > $this->clock->now()) { + return self::FORCE_GRANT; + } + + } + } + } + + return self::ABSTAIN; + } + + /** + * @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN' + */ + public function isAllowedByWorkflowForWriteOperation(object $entity): string + { + $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); + $runningWorkflows = []; + + // if a workflow is finalized positive, we are not allowed to edit to document any more foreach ($entityWorkflows as $entityWorkflow) { - // if the user is finalized, we have to check if the workflow is finalPositive, or not if ($entityWorkflow->isFinal()) { $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); @@ -41,12 +112,40 @@ class WorkflowRelatedEntityPermissionHelper $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); if (true === ($placeMetadata['isFinalPositive'] ?? false)) { // the workflow is final, and final positive, so we stop here. - return false; + return self::FORCE_DENIED; + } + } + } else { + $runningWorkflows[] = $entityWorkflow; + } + } + + // if there is a signature on a **running workflow**, no one can edit the workflow any more + foreach ($runningWorkflows as $entityWorkflow) { + foreach ($entityWorkflow->getSteps() as $step) { + foreach ($step->getSignatures() as $signature) { + if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { + return self::FORCE_DENIED; } } } } + // allow only the users involved + if ($this->isUserInvolvedInAWorkflow($runningWorkflows)) { + return self::FORCE_GRANT; + } + + return self::ABSTAIN; + } + + /** + * @param list $entityWorkflows + */ + private function isUserInvolvedInAWorkflow(array $entityWorkflows): bool + { + $currentUser = $this->security->getUser(); + foreach ($entityWorkflows as $entityWorkflow) { // so, the workflow is running... We return true if the current user is involved foreach ($entityWorkflow->getSteps() as $step) { @@ -58,58 +157,4 @@ class WorkflowRelatedEntityPermissionHelper return false; } - - /** - * Return true if the user is allowed to update the given object. - * - * Return false if some workflow block the edition of the object. - */ - public function notBlockedByWorkflow(object $entity): bool - { - $entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity); - $currentUser = $this->security->getUser(); - - $usersInvolved = []; - $entityWorkflowsNotFinalizedPositive = []; - foreach ($entityWorkflows as $entityWorkflow) { - // as soon as there is one signatured applyied, we are not able to - // edit the document any more - foreach ($entityWorkflow->getSteps() as $step) { - foreach ($step->getSignatures() as $signature) { - if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) { - return false; - } - } - } - - if ($entityWorkflow->isFinal()) { - $workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName()); - $marking = $workflow->getMarkingStore()->getMarking($entityWorkflow); - foreach ($marking->getPlaces() as $place => $active) { - $metadata = $workflow->getMetadataStore()->getPlaceMetadata($place); - if ($metadata['isFinalPositive'] ?? true) { - return false; - } - } - } else { - $entityWorkflowsNotFinalizedPositive[] = $entityWorkflow; - foreach ($entityWorkflow->getSteps() as $step) { - foreach ($step->getAllDestUser()->toArray() as $user) { - $usersInvolved[] = $user; - } - } - } - } - - // if there isn't any user, but a workflow, blocked - if ([] !== $entityWorkflowsNotFinalizedPositive) { - if ([] === $usersInvolved) { - return false; - } - - return in_array($currentUser, $usersInvolved, true); - } - - return true; - } }