feat: guard Project status transitions

This commit is contained in:
2026-07-09 19:06:47 +02:00
parent 1b34869c15
commit 7cd124a213
2 changed files with 76 additions and 12 deletions
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Service\Project;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectStatus;
use App\Entity\User\User;
use App\Security\Voter\UserOwnedVoterTrait;
class ProjectService
{
use UserOwnedVoterTrait;
public const OWNER_VALID_TRANSITIONS = [
ProjectStatus::InDraft->value => [ProjectStatus::ToCampaignReview],
ProjectStatus::InCampaignReviewToChange->value => [ProjectStatus::InCampaignReviewToReview],
ProjectStatus::ToCampaign->value => [ProjectStatus::InCampaign],
ProjectStatus::InFundingReviewToChange->value => [ProjectStatus::InFundingReviewToReview],
ProjectStatus::ToFunding->value => [ProjectStatus::InFunding],
];
/**
* Check if an User can move a Project to some status.
*
* @param User $actor The User that is to apply the status change
* @param Project $project The Project in their current (pre-change) state
* @param ProjectStatus $to The ProjectStatus that the actor wishes to move the Project to
*/
public function canTransition(User $actor, Project $project, ProjectStatus $to): bool
{
if ($actor->hasRoles(['ROLE_ADMIN'])) {
return true;
}
if (!$this->isOwnerOf($project, $actor)) {
return false;
}
$from = $project->getStatus();
if (
array_key_exists($from->value, self::OWNER_VALID_TRANSITIONS)
&& in_array($to, self::OWNER_VALID_TRANSITIONS[$from->value])
) {
return true;
}
return false;
}
}
+26 -12
View File
@@ -2,6 +2,7 @@
namespace App\State\Project;
use ApiPlatform\Metadata\Exception\AccessDeniedException;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Project\ProjectApiResource;
@@ -10,6 +11,7 @@ use App\Dto\ProjectUpdationDto;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCalendar;
use App\Mapping\AutoMapper;
use App\Service\Project\ProjectService;
use App\State\EntityStateProcessor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
@@ -20,22 +22,20 @@ class ProjectStateProcessor implements ProcessorInterface
private EntityStateProcessor $entityStateProcessor,
private AutoMapper $autoMapper,
private Security $security,
private ProjectService $projectService,
) {}
/**
* @param ProjectCreationDto|ProjectUpdationDto $data
* @param array{id: int} $uriVariables
*
* @return ProjectApiResource|null
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if ($data instanceof ProjectCreationDto) {
$project = $this->getProjectFromCreation($data);
}
if ($data instanceof ProjectUpdationDto) {
$project = $this->getProjectFromUpdate($data, $uriVariables);
} elseif ($data instanceof ProjectUpdationDto) {
$project = $this->getProjectFromUpdate($data, $context);
} else {
throw new \InvalidArgumentException('Unsupported input for Project resource');
}
$project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context);
@@ -72,13 +72,27 @@ class ProjectStateProcessor implements ProcessorInterface
return $project;
}
private function getProjectFromUpdate(ProjectUpdationDto $data, array $uriVariables): Project
private function getProjectFromUpdate(ProjectUpdationDto $data, array $context): Project
{
$data = $this->autoMapper->map($uriVariables, $data);
/** @var Project */
$project = $this->autoMapper->map($data, Project::class);
$project = $this->autoMapper->map($context['previous_data'], Project::class);
return $project;
$actor = $this->security->getUser();
if (!$actor) {
throw new AuthenticationException();
}
if ($data->status !== $project->getStatus()) {
if (!$this->projectService->canTransition($actor, $project, $data->status)) {
throw new AccessDeniedException(\sprintf(
"Cannot move the Project from status '%s' to '%s'",
$project->getStatus()->value,
$data->status->value
));
}
}
return $this->autoMapper->map($data, $project);
}
}