Skip to content

Commit

Permalink
feat(NodeVoter): Added a NodeVoter and deprecated `validateNodeAccess…
Browse files Browse the repository at this point in the history
…ForRole` method

BREAKING CHANGE: `validateNodeAccessForRole` is deprecated, use `denyAccessUnlessGranted` or `isGranted` with one of `NodeVoter` attribute and a Node instance
  • Loading branch information
ambroisemaupate committed Sep 4, 2023
1 parent c517a83 commit f6de0ee
Show file tree
Hide file tree
Showing 28 changed files with 572 additions and 701 deletions.
1 change: 1 addition & 0 deletions lib/RoadizCompatBundle/src/Controller/AppController.php
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ public function publishErrorMessage(Request $request, string $msg, ?object $sour
* @return void
*
* @throws AccessDeniedException
* @deprecated Use denyAccessUnlessGranted with NodeVoter attribute and a Node subject.
*/
public function validateNodeAccessForRole(mixed $attributes, mixed $nodeId = null, bool $includeChroot = false): void
{
Expand Down
38 changes: 21 additions & 17 deletions lib/RoadizCoreBundle/src/Node/UniqueNodeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,29 @@

namespace RZ\Roadiz\CoreBundle\Node;

use DateTime;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
use Doctrine\Persistence\ManagerRegistry;
use RZ\Roadiz\Core\AbstractEntities\TranslationInterface;
use RZ\Roadiz\CoreBundle\Entity\Node;
use RZ\Roadiz\CoreBundle\Entity\NodesSources;
use RZ\Roadiz\CoreBundle\Entity\NodeType;
use RZ\Roadiz\CoreBundle\Entity\Tag;
use RZ\Roadiz\CoreBundle\Entity\Translation;
use RZ\Roadiz\CoreBundle\Security\Authorization\Voter\NodeVoter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;

class UniqueNodeGenerator
{
protected NodeNamePolicyInterface $nodeNamePolicy;
private ManagerRegistry $managerRegistry;

/**
* @param ManagerRegistry $managerRegistry
* @param NodeNamePolicyInterface $nodeNamePolicy
*/
public function __construct(ManagerRegistry $managerRegistry, NodeNamePolicyInterface $nodeNamePolicy)
{
$this->nodeNamePolicy = $nodeNamePolicy;
$this->managerRegistry = $managerRegistry;
public function __construct(
protected ManagerRegistry $managerRegistry,
protected NodeNamePolicyInterface $nodeNamePolicy,
protected Security $security,
) {
}

/**
Expand Down Expand Up @@ -55,9 +55,7 @@ public function generate(
if (null !== $tag) {
$node->addTag($tag);
}
if (null !== $parent) {
$parent->addChild($node);
}
$parent?->addChild($node);

if ($pushToTop) {
/*
Expand All @@ -71,7 +69,7 @@ public function generate(

$source = new $sourceClass($node, $translation);
$source->setTitle($name);
$source->setPublishedAt(new \DateTime());
$source->setPublishedAt(new DateTime());
$node->setNodeName($this->nodeNamePolicy->getCanonicalNodeName($source));

$manager = $this->managerRegistry->getManagerForClass(Node::class);
Expand All @@ -90,8 +88,8 @@ public function generate(
* @param Request $request
*
* @return NodesSources
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @throws ORMException
* @throws OptimisticLockException
*/
public function generateFromRequest(Request $request): NodesSources
{
Expand All @@ -113,7 +111,13 @@ public function generateFromRequest(Request $request): NodesSources
$parent = $this->managerRegistry
->getRepository(Node::class)
->find((int) $request->get('parentNodeId'));
if (null === $parent || !$this->security->isGranted(NodeVoter::CREATE, $parent)) {
throw new BadRequestHttpException("Parent node does not exist.");
}
} else {
if (!$this->security->isGranted(NodeVoter::CREATE_AT_ROOT)) {
throw new AccessDeniedException('You are not allowed to create a node at root.');
}
$parent = null;
}

Expand Down
10 changes: 5 additions & 5 deletions lib/RoadizCoreBundle/src/Repository/NodeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -906,15 +906,15 @@ public function findByReverseNodeAndFieldAndTranslation(

/**
* @param Node $node
* @return array
* @return array<int>
*/
public function findAllOffspringIdByNode(Node $node)
public function findAllOffspringIdByNode(Node $node): array
{
$theOffprings = [];
$theOffsprings = [];
$in = [$node->getId()];

do {
$theOffprings = array_merge($theOffprings, $in);
$theOffsprings = array_merge($theOffsprings, $in);
$subQb = $this->createQueryBuilder('n');
$subQb->select('n.id')
->andWhere($subQb->expr()->in('n.parent', ':tab'))
Expand All @@ -928,7 +928,7 @@ public function findAllOffspringIdByNode(Node $node)
$in[] = (int) $item['id'];
}
} while (!empty($in));
return $theOffprings;
return $theOffsprings;
}

/**
Expand Down
233 changes: 233 additions & 0 deletions lib/RoadizCoreBundle/src/Security/Authorization/Voter/NodeVoter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<?php

declare(strict_types=1);

namespace RZ\Roadiz\CoreBundle\Security\Authorization\Voter;

use Psr\Cache\CacheItemPoolInterface;
use RZ\Roadiz\Core\Handlers\HandlerFactoryInterface;
use RZ\Roadiz\CoreBundle\Entity\Node;
use RZ\Roadiz\CoreBundle\Entity\NodesSources;
use RZ\Roadiz\CoreBundle\Entity\User;
use RZ\Roadiz\CoreBundle\EntityHandler\NodeHandler;
use RZ\Roadiz\CoreBundle\Security\Authorization\Chroot\NodeChrootResolver;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;

final class NodeVoter extends Voter
{
public const CREATE = 'CREATE';
public const DUPLICATE = 'DUPLICATE';
public const CREATE_AT_ROOT = 'CREATE_AT_ROOT';
public const READ = 'READ';
public const READ_AT_ROOT = 'READ_AT_ROOT';
public const EMPTY_TRASH = 'EMPTY_TRASH';
public const READ_LOGS = 'READ_LOGS';
public const EDIT_CONTENT = 'EDIT_CONTENT';
public const EDIT_TAGS = 'EDIT_TAGS';
public const EDIT_REALMS = 'EDIT_REALMS';
public const EDIT_SETTING = 'EDIT_SETTING';
public const EDIT_STATUS = 'EDIT_STATUS';
public const EDIT_ATTRIBUTE = 'EDIT_ATTRIBUTE';
public const DELETE = 'DELETE';

public function __construct(
private NodeChrootResolver $chrootResolver,
private Security $security,
private HandlerFactoryInterface $handlerFactory,
private CacheItemPoolInterface $cache
)
{
}

protected function supports(string $attribute, $subject): bool
{
if (\in_array($attribute, [
self::CREATE_AT_ROOT,
self::READ_AT_ROOT,
self::EMPTY_TRASH,
])) {
return true;
}

if (!\in_array($attribute, [
self::CREATE,
self::DUPLICATE,
self::READ,
self::READ_LOGS,
self::EDIT_CONTENT,
self::EDIT_SETTING,
self::EDIT_TAGS,
self::EDIT_REALMS,
self::EDIT_STATUS,
self::EDIT_ATTRIBUTE,
self::DELETE
])) {
return false;
}

if ($subject instanceof Node || $subject instanceof NodesSources) {
return true;
}

return false;
}

protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();

if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}

if ($subject instanceof NodesSources) {
$subject = $subject->getNode();
}

return match($attribute) {
self::CREATE => $this->canCreate($subject, $user),
self::DUPLICATE => $this->canDuplicate($subject, $user),
self::CREATE_AT_ROOT => $this->canCreateAtRoot($user),
self::READ => $this->canRead($subject, $user),
self::READ_AT_ROOT => $this->canReadAtRoot($user),
self::READ_LOGS => $this->canReadLogs($subject, $user),
self::EDIT_CONTENT => $this->canEditContent($subject, $user),
self::EDIT_SETTING => $this->canEditSetting($subject, $user),
self::EDIT_STATUS => $this->canEditStatus($subject, $user),
self::EDIT_TAGS => $this->canEditTags($subject, $user),
self::EDIT_REALMS => $this->canEditRealms($subject, $user),
self::EDIT_ATTRIBUTE => $this->canEditAttribute($subject, $user),
self::DELETE => $this->canDelete($subject, $user),
self::EMPTY_TRASH => $this->canEmptyTrash($user),
default => throw new \LogicException('This code should not be reached!')
};
}

private function isNodeInsideUserChroot(Node $node, Node $chroot, bool $includeChroot = false): bool
{
if (!$includeChroot && $chroot->getId() === $node->getId()) {
return false;
}

/*
* Test if node is inside user chroot using all Chroot node offspring ids
* to be able to cache all results.
*/
$cacheItem = $this->cache->getItem('node_offspring_ids_' . $chroot->getId());
if (!$cacheItem->isHit()) {
/** @var NodeHandler $nodeHandler */
$nodeHandler = $this->handlerFactory->getHandler($chroot);
$offspringIds = $nodeHandler->getAllOffspringId();
$cacheItem->set($offspringIds);
$this->cache->save($cacheItem);
} else {
$offspringIds = $cacheItem->get();
}

return \in_array($node->getId(), $offspringIds, true);
}

private function isGrantedWithUserChroot(Node $node, User $user, array|string $roles, bool $includeChroot): bool
{
$chroot = $this->chrootResolver->getChroot($user);
if (null === $chroot) {
return $this->security->isGranted($roles);
}

return $this->security->isGranted($roles) &&
$this->isNodeInsideUserChroot($node, $chroot, $includeChroot);
}

private function canCreateAtRoot(User $user): bool
{
$chroot = $this->chrootResolver->getChroot($user);
return null === $chroot && $this->security->isGranted('ROLE_ACCESS_NODES');
}

private function canReadAtRoot(User $user): bool
{
$chroot = $this->chrootResolver->getChroot($user);
return null === $chroot && $this->security->isGranted('ROLE_ACCESS_NODES');
}

private function canEmptyTrash(User $user): bool
{
$chroot = $this->chrootResolver->getChroot($user);
return null === $chroot && $this->security->isGranted('ROLE_ACCESS_NODES_DELETE');
}


private function canCreate(Node $node, User $user): bool
{
/*
* Creation is allowed only if node is inside user chroot,
* user CAN create a chroot child.
*/
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES', true);
}

private function canRead(Node $node, User $user): bool
{
/*
* Read is allowed only if node is inside user chroot,
* user CAN read or list the chroot node children.
*/
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES', true);
}

private function canReadLogs(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, ['ROLE_ACCESS_NODES', 'ROLE_ACCESS_LOGS'], false);
}

private function canEditContent(Node $node, User $user): bool
{
/*
* Edition is allowed only if node is inside user chroot,
* user cannot edit its chroot content.
*/
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES', false);
}

private function canEditTags(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, ['ROLE_ACCESS_NODES', 'ROLE_ACCESS_TAGS'], false);
}

private function canEditRealms(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, ['ROLE_ACCESS_NODES', 'ROLE_ACCESS_REALM_NODES'], false);
}

private function canDuplicate(Node $node, User $user): bool
{
/*
* Duplication is allowed only if node is inside user chroot,
* user cannot duplicate its chroot.
*/
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES', false);
}

private function canEditSetting(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES_SETTING', false);
}

private function canEditStatus(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES_STATUS', false);
}

private function canDelete(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODES_DELETE', false);
}

private function canEditAttribute(Node $node, User $user): bool
{
return $this->isGrantedWithUserChroot($node, $user, 'ROLE_ACCESS_NODE_ATTRIBUTES', false);
}
}
Loading

0 comments on commit f6de0ee

Please sign in to comment.