Search code examples
phpsymfonyrolesacl

Symfony - Efficient access control for (dynamic) hierarchical roles


I need some advice on how to handle access control for the following scenario:

  • Corporation
    • Has one or many companies
    • Has one or many ROLE_CORP_ADMIN
  • Company
    • Has one or many regions.
    • Has one or many ROLE_COMPANY_ADMIN.
  • Region:
    • Has zero or many stores.
    • Has one or many ROLE_REGION_ADMIN.
  • Store:
    • Has zero or many assets.
    • Has one or many ROLE_STORE_ADMIN.
    • Has zero or many ROLE_STORE_EMPLOYEE.
    • Has zero or many ROLE_STORE_CUSTOMER (many is better).

The application should support many corporations.

My instinct is to create either a many-to-many relationship per entity for their admins (eg region_id, user_id). Depending on performance, I could go with a more denormalized table with user_id, corporation_id, company_id, region_id, and store_id. Then I'd create a voter class (unanimous strategy):

public function vote(TokenInterface $token, $object, array $attributes)
{
    // If SUPER_ADMIN, return ACCESS_GRANTED
    // If User in $object->getAdmins(), return ACCESS_GRANTED
    // Else, return ACCESS_DENIED
}

Since the permissions are hierarchical, the getAdmins() function will check all owners for admins as well. For instance: $region->getAdmins() will also return admins for the owning company, and corporation.

I feel like I'm missing something obvious. Depending on how I implement the getAdmins() function, this approach will require at least one hit to the db every vote. Is there a "better" way to go about this?

Thanks in advance for your help.


Solution

  • I did just what I posed above, and it is working well. The voter was easy to implement per the Symfony cookbook. The many-to-many <entity>_owners tables work fine.

    To handle the hierarchical permissions, I used cascading calls in the entities. Not elegant, not efficient, but not to bad in terms of speed. I'm sure refactor this to use a single DQL query soon, but cascading calls work for now:

    class Store implements OwnableInterface
    {
        ....
    
        /**
         * @ORM\ManyToMany(targetEntity="Person")
         * @ORM\JoinTable(name="stores_owners",
         *      joinColumns={@ORM\JoinColumn(name="store_id", referencedColumnName="id", nullable=true)},
         *      inverseJoinColumns={@ORM\JoinColumn(name="person_id", referencedColumnName="id")}
         *      )
         *
         * @var ArrayCollection|Person[]
         */
        protected $owners;
    
        ...
    
        public function __construct()
        {
            $this->owners = new ArrayCollection();
        }
    
        ...
    
        /**
         * Returns all people who are owners of the object
         * @return ArrayCollection|Person[]
         */
        function getOwners()
        {
            $effectiveOwners = new ArrayCollection();
    
            foreach($this->owners as $owner){
                $effectiveOwners->add($owner);
            }
    
            foreach($this->getRegion()->getOwners() as $owner){
                $effectiveOwners->add($owner);
            }
    
            return $effectiveOwners;
        }
    
        /**
         * Returns true if the person is an owner.
         * @param Person $person
         * @return boolean
         */
        function isOwner(Person $person)
        {
            return ($this->getOwners()->contains($person));
        }
    
        ...
    
    }
    

    The Region entity would also implement OwnableInterface and its getOwners() would then call getCompany()->getOwners(), etc.

    There were problems with array_merge if there were no owners (null), so the new $effectiveOwners ArrayCollection seems to work well.

    Here is the voter. I stole most of the voter code and OwnableInterface and OwnerInterface from KnpRadBundle:

    use Acme\AcmeBundle\Security\OwnableInterface;
    use Acme\AcmeBundle\Security\OwnerInterface;
    use Acme\AcmeUserBundle\Entity\User;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
    
    class IsOwnerVoter implements VoterInterface
    {
    
        const IS_OWNER = 'IS_OWNER';
    
        private $container;
    
        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container) {
            $this->container = $container;
        }
    
        public function supportsAttribute($attribute)
        {
            return self::IS_OWNER === $attribute;
        }
    
        public function supportsClass($class)
        {
            if (is_object($class)) {
                $ref = new \ReflectionObject($class);
    
                return $ref->implementsInterface('Acme\AcmeBundle\Security\OwnableInterface');
            }
    
            return false;
        }
    
        public function vote(TokenInterface $token, $object, array $attributes)
        {
            foreach ($attributes as $attribute) {
    
                if (!$this->supportsAttribute($attribute)) {
                    continue;
                }
    
                if (!$this->supportsClass($object)) {
                    return self::ACCESS_ABSTAIN;
                }
    
                // Is the token a super user? This will check roles, not user.
                if ( $this->container->get('security.context')->isGranted('ROLE_SUPER_ADMIN') ) {
                    return VoterInterface::ACCESS_GRANTED;
                }
    
                if (!$token->getUser() instanceof User) {
                    return self::ACCESS_ABSTAIN;
                }
    
                // check to see if this token is a user.
                if (!$token->getUser()->getPerson() instanceof OwnerInterface) {
                    return self::ACCESS_ABSTAIN;
                }
    
                // Is this person an owner?
                if ($this->isOwner($token->getUser()->getPerson(), $object)) {
                    return self::ACCESS_GRANTED;
                }
    
                return self::ACCESS_DENIED;
            }
    
            return self::ACCESS_ABSTAIN;
        }
    
        private function isOwner(OwnerInterface $owner, OwnableInterface $ownable)
        {
            return $ownable->isOwner($owner);
        }
    }