diff --git a/Doctrine/EventSubscriber/EntityListener.php b/Doctrine/EventSubscriber/EntityListener.php index e13de44..ef1f8cc 100644 --- a/Doctrine/EventSubscriber/EntityListener.php +++ b/Doctrine/EventSubscriber/EntityListener.php @@ -14,47 +14,72 @@ namespace Plugin\Api42\Doctrine\EventSubscriber; use Doctrine\Common\EventSubscriber; -use Doctrine\Persistence\Event\LifecycleEventArgs; +use Doctrine\ORM\Event\PrePersistEventArgs; +use Doctrine\ORM\Event\PreRemoveEventArgs; +use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Events; +use Doctrine\Persistence\Event\LifecycleEventArgs; +use GraphQL\Error\Error; +use Plugin\Api42\GraphQL\EntityAccessPolicy; use Plugin\Api42\Service\WebHookEvents; class EntityListener implements EventSubscriber { - /** - * @var WebHookEvents - */ - private $webHookEvents; + private WebHookEvents $webHookEvents; + + private EntityAccessPolicy $accessPolicy; /** * EntityListener constructor. - * @param WebHookEvents $webHookEvents */ - public function __construct(WebHookEvents $webHookEvents) + public function __construct(WebHookEvents $webHookEvents, EntityAccessPolicy $accessPolicy) { $this->webHookEvents = $webHookEvents; + $this->accessPolicy = $accessPolicy; } - public function getSubscribedEvents() + public function getSubscribedEvents(): array { return [ + Events::prePersist, + Events::preUpdate, + Events::preRemove, Events::postPersist, Events::postUpdate, - Events::preRemove, ]; } - public function postPersist(LifecycleEventArgs $args) + public function prePersist(PrePersistEventArgs $args): void + { + $this->validateEntityScope($args->getObject()); + } + + public function preUpdate(PreUpdateEventArgs $args): void + { + $this->validateEntityScope($args->getObject()); + } + + public function postPersist(LifecycleEventArgs $args): void { $this->webHookEvents->onCreated($args->getObject()); } - public function postUpdate(LifecycleEventArgs $args) + public function postUpdate(LifecycleEventArgs $args): void { $this->webHookEvents->onUpdated($args->getObject()); } - public function preRemove(LifecycleEventArgs $args) + public function preRemove(PreRemoveEventArgs $args): void { + $this->validateEntityScope($args->getObject()); $this->webHookEvents->onDeleted($args->getObject()); } + + private function validateEntityScope(object $entity): void + { + $entityClass = get_class($entity); + if ($this->accessPolicy->canWriteEntity($entityClass) === false) { + throw new Error("Cannot write entity. `{$entityClass}`"); + } + } } diff --git a/GraphQL/EntityAccessPolicy.php b/GraphQL/EntityAccessPolicy.php index fd81bd9..8efe30b 100644 --- a/GraphQL/EntityAccessPolicy.php +++ b/GraphQL/EntityAccessPolicy.php @@ -15,6 +15,7 @@ use Eccube\Request\Context; use Eccube\Security\SecurityContext; +use Symfony\Component\HttpFoundation\RequestStack; class EntityAccessPolicy { @@ -26,28 +27,23 @@ class EntityAccessPolicy private Context $requestContext; - public function __construct(SecurityContext $securityContext, Context $requestContext) + private RequestStack $requestStack; + + public function __construct(SecurityContext $securityContext, Context $requestContext, RequestStack $requestStack) { $this->securityContext = $securityContext; $this->requestContext = $requestContext; + $this->requestStack = $requestStack; } public function canReadEntity(string $entityClass): bool { - if (is_null($this->securityContext->getLoginUser())) { - return !empty(array_filter($this->frontAllowLists, function (AllowList $al) use ($entityClass) { - return $al->isAllowed($entityClass); - })); - } - // TODO 管理画面をAPIで実装するまでは管理者画面URL以下でアクセスした場合はすべてのEntityを許可する if ($this->requestContext->isAdmin()) { return true; } - $role = 'ROLE_OAUTH2_READ:'.strtoupper((new \ReflectionClass($entityClass))->getShortName()); - - return $this->securityContext->isGranted($role); + return $this->canAccessEntity($entityClass, false); } public function canReadProperty(string $entityClass, $fieldName): bool @@ -64,13 +60,41 @@ public function canReadProperty(string $entityClass, $fieldName): bool return !empty($allowed); } - public function addAllowList(AllowList $allowList) + public function canWriteEntity(string $entityClass): bool + { + if (!$this->isApiRequest()) { + return true; + } + + return $this->canAccessEntity($entityClass, true); + } + + private function canAccessEntity(string $entityClass, bool $write): bool + { + if (is_null($this->securityContext->getLoginUser())) { + return !empty(array_filter($this->frontAllowLists, function (AllowList $al) use ($entityClass) { + return $al->isAllowed($entityClass); + })); + } + + $access = $write ? 'WRITE' : 'READ'; + $role = "ROLE_OAUTH2_{$access}:".strtoupper((new \ReflectionClass($entityClass))->getShortName()); + + return $this->securityContext->isGranted($role); + } + public function addAllowList(AllowList $allowList): void { $this->allowLists[] = $allowList; } - public function addFrontAllowList(AllowList $allowList) + public function addFrontAllowList(AllowList $allowList): void { $this->frontAllowLists[] = $allowList; } + + private function isApiRequest(): bool + { + $mainRequest = $this->requestStack->getMainRequest(); + return $mainRequest != null && $mainRequest->getPathInfo() === '/api'; + } } diff --git a/Tests/Web/ApiControllerTest.php b/Tests/Web/ApiControllerTest.php index 6c8e527..530c330 100644 --- a/Tests/Web/ApiControllerTest.php +++ b/Tests/Web/ApiControllerTest.php @@ -90,6 +90,9 @@ public function permissionProvider() [['read:Customer'], '{ customer(id:1) { id, password } }', 'Cannot query field "password" on type "Customer".'], [null, '{ product(id:1) { id, name } }'], [null, '{ product(id:1) { id, name, Creator { id } } }', 'Cannot query field "Creator" on type "Product".'], + [['read:ProductClass'], 'mutation { updateProductStock(code: "sand-01", stock: 10, stock_unlimited:false) { id } }', 'Cannot write entity. `Eccube\\Entity\\ProductClass`'], + [['read:ProductClass', 'write:ProductClass'], 'mutation { updateProductStock(code: "sand-01", stock: 10, stock_unlimited:false) { id } }', 'Cannot write entity. `Eccube\\Entity\\ProductStock`'], + [['read:ProductClass', 'write:ProductClass', 'write:ProductStock'], 'mutation { updateProductStock(code: "sand-01", stock: 10, stock_unlimited:false) { id } }'], ]; }