Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter child entity from parent ACL #6

Closed
MrGreenStuff opened this issue Jan 8, 2014 · 6 comments
Closed

Filter child entity from parent ACL #6

MrGreenStuff opened this issue Jan 8, 2014 · 6 comments

Comments

@MrGreenStuff
Copy link

I every one,

First thanks for sharing this awesome bundle !

I have and idea for enhance the code : filter child entity from parent ACL (optionnal).

Exemple an application with 2 tables (Category and Product) a user who have OPERATOR ACL access to a specific category can view only the products of this category.

I will try to do this but if somebody have some good ideas ?

My problem is i don't know how i can make it optionnal (how i can param it) and how i can specify wich parent entity to choose for filtering.

If i success to code it i will send it tou you for merge.

Bye

@dunglas
Copy link
Member

dunglas commented Jan 8, 2014

Hi @MrGreenStuff and thanks for your feedback.

If I understand the use case, you can already achieve your need with the current code:

Is this what you want?

If you create objects outside of the admin scope (e.g. in a frontend controller), you cannot use the createObjectSecurity() method and you need to deal directly with the Symfony Security Component to copy ACL from the parent object.
If it's interesting you, at @coopTilleuls we should have a code sample doing that.

@sroze
Copy link
Contributor

sroze commented Jan 8, 2014

@MrGreenStuff
Copy link
Author

Ok it's want i want to do. Thanks very much.

@MrGreenStuff
Copy link
Author

After some test it's not exactly what i search. So i modify the code.
I reexplain the behavior i expect example :

3 Tables : Shop, Product and Country

  • Between this tables relation ManyToOne (1 Country have N Shop) (1 Shop have N products)

3 Users :

  • MainManager (NOT SUPER ADMIN !)
  • EnglandManager
  • FranceManager

Behavior expected :

  • MainManager have OPERATOR ACL on all Countries and can access to all shop and products of the matching country (even if ACL record for him not exists but because they have ACL access to the parent or the grand parent in this case all countries)
  • EnglandManager or FranceManager can acces to all shop and products of the matching coutry (even if the products or shop has been created by MainManager or the SUPER_ADMIN without ACLs for this users but because they have ACL acces to the parent or the grand parent in this case only one country)

So i made a major modification to the Admin/AclAdminExtension.php ( configureQuery method ) :

<?php

/*
 * (c) La Coopérative des Tilleuls <[email protected]>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 *
 *
 * Modified By JUILLARD Yoann
 */

namespace CoopTilleuls\Bundle\AclSonataAdminExtensionBundle\Admin;

use Sonata\AdminBundle\Admin\AdminExtension;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Doctrine\DBAL\Connection;
use Symfony\Component\Security\Acl\Permission\MaskBuilder;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Acl\Domain\UserSecurityIdentity;

/**
 * Admin extension filtering the list
 *
 * @author Kévin Dunglas <[email protected]>
 */
class AclAdminExtension extends AdminExtension
{
    /**
     * @var SecurityContextInterface
     */
    protected $securityContext;
    /**
     * @var Connection
     */
    protected $databaseConnection;

    /**
     * @param SecurityContextInterface $securityContext
     * @param Connection               $databaseConnection
     */
    public function __construct(SecurityContextInterface $securityContext, Connection $databaseConnection)
    {
        $this->securityContext = $securityContext;
        $this->databaseConnection = $databaseConnection;
    }

    /**
     * Filters with ACL
     *
     * @param  AdminInterface      $admin
     * @param  ProxyQueryInterface $query
     * @param  string              $context
     * @throws \RuntimeException
     */
    public function configureQuery(AdminInterface $admin, ProxyQueryInterface $query, $context = 'list')
    {
        // Don't filter for admins and for not ACL enabled classes and for command cli
        if (!$admin->isAclEnabled() || !$this->securityContext->getToken() || $admin->isGranted(sprintf($admin->getSecurityHandler()->getBaseRole($admin), 'ADMIN'))) {
            return;
        }

        // Retrieve current logged user SecurityIdentity
        $user = $this->securityContext->getToken()->getUser();
        $securityIdentity = UserSecurityIdentity::fromAccount($user);

        // Get identity ACL identifier
        $identifier = sprintf('%s-%s', $securityIdentity->getClass(), $securityIdentity->getUsername());

        $identityStmt = $this->databaseConnection->prepare('SELECT id FROM acl_security_identities WHERE identifier = :identifier');
        $identityStmt->bindValue('identifier', $identifier);
        $identityStmt->execute();

        $identityId = $identityStmt->fetchColumn();

        // Get class ACL identifier
        $classType = $admin->getClass();
        $classStmt = $this->databaseConnection->prepare('SELECT id FROM acl_classes WHERE class_type = :classType');
        $classStmt->bindValue('classType', $classType);
        $classStmt->execute();

        $classId = $classStmt->fetchColumn();
        if ($identityId && $classId) {
            $entriesStmt = $this->databaseConnection->prepare('SELECT object_identifier FROM acl_entries AS ae JOIN acl_object_identities AS aoi ON ae.object_identity_id = aoi.id WHERE ae.class_id = :classId AND ae.security_identity_id = :identityId AND (:view = ae.mask & :view OR :operator = ae.mask & :operator OR :master = ae.mask & :master OR :owner = ae.mask & :owner)');
            $entriesStmt->bindValue('classId', $classId);
            $entriesStmt->bindValue('identityId', $identityId);
            $entriesStmt->bindValue('view', MaskBuilder::MASK_VIEW);
            $entriesStmt->bindValue('operator', MaskBuilder::MASK_OPERATOR);
            $entriesStmt->bindValue('master', MaskBuilder::MASK_MASTER);
            $entriesStmt->bindValue('owner', MaskBuilder::MASK_OWNER);
            $entriesStmt->execute();

            $ids = array();
            foreach ($entriesStmt->fetchAll() as $row) {
                $ids[] = $row['object_identifier'];
            }
            //IF THERE IS NOT DIRECT RESULT WE MADE A QUERY ON THE MASTER ACL CLASS
            //Test if method getMasterACLclass and getPathToMasterACL exist on the admin CLASS -> SEE THE DOC
            if (count($ids)==0 && method_exists($admin,'getMasterACLclass') && method_exists($admin,'getPathToMasterACL')) {
                $classStmt = $this->databaseConnection->prepare('SELECT id FROM acl_classes WHERE class_type = :classType');
                //QUERY ON MASTER ACL CLASS (method $admin->getMasterACLclass() return a string like 'Acme\Bundle\Entity\MasterACLEntity');
                $classStmt->bindValue('classType', $admin->getMasterACLclass());
                $classStmt->execute();

                $classId = $classStmt->fetchColumn();
                $entriesStmt = $this->databaseConnection->prepare('SELECT object_identifier FROM acl_entries AS ae JOIN acl_object_identities AS aoi ON ae.object_identity_id = aoi.id WHERE ae.class_id = :classId AND ae.security_identity_id = :identityId AND (:view = ae.mask & :view OR :operator = ae.mask & :operator OR :master = ae.mask & :master OR :owner = ae.mask & :owner)');
                $entriesStmt->bindValue('classId', $classId);
                $entriesStmt->bindValue('identityId', $identityId);
                $entriesStmt->bindValue('view', MaskBuilder::MASK_VIEW);
                $entriesStmt->bindValue('operator', MaskBuilder::MASK_OPERATOR);
                $entriesStmt->bindValue('master', MaskBuilder::MASK_MASTER);
                $entriesStmt->bindValue('owner', MaskBuilder::MASK_OWNER);
                $entriesStmt->execute();
                //ARRAY OF IDS
                $ids = array();
                foreach ($entriesStmt->fetchAll() as $row) {
                    $ids[] = $row['object_identifier'];
                }
                /*The method $admin->getPathToMasterACL() have to return an array (BE CAREFULL OF ORDER) like : 
                    array(
                        array('parent','p1'),
                        array('grandParent','p2'),
                        array('grandGrandParent','p3')
                        ...
                    )
                    TODO MADE SHORTCUT AUTOMATIC
                */
                $parents=$admin->getPathToMasterACL();
                //HERE UPDATE THE QUERY
                foreach($parents as $key=>$parent){
                    //FIRST shorcut is 'o'
                    if($key==0){
                        $query->leftJoin('o.'.$parent[0],$parent[1]);
                    }else{
                    //Shortcut is precedent shortcut
                        $query->leftJoin($parents[$key-1][1].'.'.$parent[0],$parent[1]);
                    }
                    //HERE WE ARE AFTER THE LEFT JOIN ON MASTER ACL CLASS WE PASS ids array param
                    if(($key+1)==count($parents)){
                        $query->andWhere($parent[1].'.id IN (:ids'.$key.')')
                              ->setParameter('ids'.$key, $ids);
                    }
                }
                return;
            }elseif(count($ids)){
                //NORMAL BEHAVIOR
                $query
                    ->andWhere('o.id IN (:ids)')
                    ->setParameter('ids', $ids)
                ;
                return;
            }
        }


        // Display an empty list
        $query->andWhere('1 = 2');
    }
}

And here the two method to add to your admin class for ACTIVATE (it's optionnal) and PARAM the new behavior

class AcmeAdmin extends Admin
{
    public function getMasterACLclass()
    {
        return 'Acme\AcmeBundle\Entity\MasterEntity';
    }

    public function getPathToMasterACL()
    {   
        //CAREFULL WITH ORDER
        return array(
                    array('parent','p1'),
                    array('grandParent','p2'),
                    ...
                                        ...etc
                    );
/*where the first value is the name of the property wich make the relation with the parent and second a unique selector*/
    }

Every thing works fine BUT only when the creation of ACL on concerned table is disabled and i don't know how to make that :( . I try this but it don't disble just put view ACCESS :

public function createObjectSecurity($object)
    {
        return;
    }

If you have an idea for disable all ACL creation (even owner !) on an admin class ?

Thanks for reading

@MrGreenStuff MrGreenStuff reopened this Jan 8, 2014
@MrGreenStuff
Copy link
Author

It's OK i find a solution without disabled ACL creation (and i prefer).

The solution is a OR (build via $query->expr to keep filter working) condition in certain cases.

With this version you don't have to override or touch at the default createObjectSecurity method !

Here the final version of my AclAdminExtension class :

<?php

/*
 * (c) La Coopérative des Tilleuls <[email protected]>
 *
 * This source file is subject to the MIT license that is bundled
 * with this source code in the file LICENSE.
 */

namespace CoopTilleuls\Bundle\AclSonataAdminExtensionBundle\Admin;

use Sonata\AdminBundle\Admin\AdminExtension;
use Sonata\AdminBundle\Admin\AdminInterface;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;
use Doctrine\DBAL\Connection;
use Symfony\Component\Security\Acl\Permission\MaskBuilder;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Acl\Domain\UserSecurityIdentity;

/**
 * Admin extension filtering the list
 *
 * @author Kévin Dunglas <[email protected]>
 */
class AclAdminExtension extends AdminExtension
{
    /**
     * @var SecurityContextInterface
     */
    protected $securityContext;
    /**
     * @var Connection
     */
    protected $databaseConnection;

    /**
     * @param SecurityContextInterface $securityContext
     * @param Connection               $databaseConnection
     */
    public function __construct(SecurityContextInterface $securityContext, Connection $databaseConnection)
    {
        $this->securityContext = $securityContext;
        $this->databaseConnection = $databaseConnection;
    }

    /**
     * Filters with ACL
     *
     * @param  AdminInterface      $admin
     * @param  ProxyQueryInterface $query
     * @param  string              $context
     * @throws \RuntimeException
     */
    public function configureQuery(AdminInterface $admin, ProxyQueryInterface $query, $context = 'list')
    {
        // Don't filter for admins and for not ACL enabled classes and for command cli
        if (!$admin->isAclEnabled() || !$this->securityContext->getToken() || $admin->isGranted(sprintf($admin->getSecurityHandler()->getBaseRole($admin), 'ADMIN'))) {
            return;
        }

        // Retrieve current logged user SecurityIdentity
        $user = $this->securityContext->getToken()->getUser();
        $securityIdentity = UserSecurityIdentity::fromAccount($user);

        // Get identity ACL identifier
        $identifier = sprintf('%s-%s', $securityIdentity->getClass(), $securityIdentity->getUsername());

        $identityStmt = $this->databaseConnection->prepare('SELECT id FROM acl_security_identities WHERE identifier = :identifier');
        $identityStmt->bindValue('identifier', $identifier);
        $identityStmt->execute();

        $identityId = $identityStmt->fetchColumn();

        // Get class ACL identifier
        $classType = $admin->getClass();
        $classStmt = $this->databaseConnection->prepare('SELECT id FROM acl_classes WHERE class_type = :classType');
        $classStmt->bindValue('classType', $classType);
        $classStmt->execute();

        $classId = $classStmt->fetchColumn();
        if ($identityId && $classId) {
            $entriesStmt = $this->databaseConnection->prepare('SELECT object_identifier FROM acl_entries AS ae JOIN acl_object_identities AS aoi ON ae.object_identity_id = aoi.id WHERE ae.class_id = :classId AND ae.security_identity_id = :identityId AND (:view = ae.mask & :view OR :operator = ae.mask & :operator OR :master = ae.mask & :master OR :owner = ae.mask & :owner)');
            $entriesStmt->bindValue('classId', $classId);
            $entriesStmt->bindValue('identityId', $identityId);
            $entriesStmt->bindValue('view', MaskBuilder::MASK_VIEW);
            $entriesStmt->bindValue('operator', MaskBuilder::MASK_OPERATOR);
            $entriesStmt->bindValue('master', MaskBuilder::MASK_MASTER);
            $entriesStmt->bindValue('owner', MaskBuilder::MASK_OWNER);
            $entriesStmt->execute();

            $ids = array();
            foreach ($entriesStmt->fetchAll() as $row) {
                $ids[] = $row['object_identifier'];
            }
            //IF THERE IS NOT DIRECT RESULT WE MADE A QUERY ON THE MASTER ACL CLASS
            //Test if method getMasterACLclass and getPathToMasterACL exist on the admin CLASS -> SEE THE DOC
            if (method_exists($admin,'getMasterACLclass') && method_exists($admin,'getPathToMasterACL')) {
                $classStmt = $this->databaseConnection->prepare('SELECT id FROM acl_classes WHERE class_type = :classType');
                //QUERY ON MASTER ACL CLASS (method $admin->getMasterACLclass() return a string like 'Acme\Bundle\Entity\MasterACLEntity');
                $classStmt->bindValue('classType', $admin->getMasterACLclass());
                $classStmt->execute();

                $classId = $classStmt->fetchColumn();
                $entriesStmt = $this->databaseConnection->prepare('SELECT object_identifier FROM acl_entries AS ae JOIN acl_object_identities AS aoi ON ae.object_identity_id = aoi.id WHERE ae.class_id = :classId AND ae.security_identity_id = :identityId AND (:view = ae.mask & :view OR :operator = ae.mask & :operator OR :master = ae.mask & :master OR :owner = ae.mask & :owner)');
                $entriesStmt->bindValue('classId', $classId);
                $entriesStmt->bindValue('identityId', $identityId);
                $entriesStmt->bindValue('view', MaskBuilder::MASK_VIEW);
                $entriesStmt->bindValue('operator', MaskBuilder::MASK_OPERATOR);
                $entriesStmt->bindValue('master', MaskBuilder::MASK_MASTER);
                $entriesStmt->bindValue('owner', MaskBuilder::MASK_OWNER);
                $entriesStmt->execute();
                //ARRAY OF idsMaster
                $idsMaster = array();
                foreach ($entriesStmt->fetchAll() as $row) {
                    $idsMaster[] = $row['object_identifier'];
                }
                /*The method $admin->getPathToMasterACL() have to return an array (BE CAREFULL OF ORDER) like : 
                    array(
                        array('firstChild','c1'),
                        array('secondChild','c2'),
                        array('thirdChild','c3')
                        ...
                    )
                    where the first argument is the name of the property relation and second a unique selector (TODO MADE NAME SELECTOR AUTOMATIC)
                */
                $parents=$admin->getPathToMasterACL();
                //HERE UPDATE THE QUERY
                foreach($parents as $key=>$parent){
                    //FIRST shorcut is 'o'
                    if($key==0){
                        $query->leftJoin('o.'.$parent[0],$parent[1]);
                    }else{
                    //Shortcut is precedent shortcut
                        $query->leftJoin($parents[$key-1][1].'.'.$parent[0],$parent[1]);
                    }
                    //HERE WE ARE AFTER THE LEFT JOIN ON MASTER ACL CLASS WE PASS idsMaster array param
                    if(($key+1)==count($parents)){
                        //HERE FOR OBJECT CREATED BY CURRENT USER
                        if(count($ids)){
                            //création de l'expression OR EXPRESSION
                            $orCondition = $query->expr()->orx();
                            $orCondition->add($query->expr()->in('o.id', ':ids'));
                            $orCondition->add($query->expr()->in($parent[1].'.id',':idsMaster'));
                            $query->andWhere($orCondition)->setParameter('ids', $ids)->setParameter('idsMaster', $idsMaster);
                        }else{
                            $query->andWhere($parent[1].'.id IN (:idsMaster'.$key.')')->setParameter('idsMaster'.$key, $idsMaster);
                        }
                    }
                }
                return;
            }elseif(count($ids)){
                //NORMAL BEHAVIOR
                $query
                    ->andWhere('o.id IN (:ids)')
                    ->setParameter('ids', $ids)
                ;
                return;
            }
        }


        // Display an empty list
        $query->andWhere('1 = 2');
    }
}

@MrGreenStuff
Copy link
Author

I made a fork with this enhancements here : MrGreenStuffAclSonataAdminExtensionBundle.

On packagist : mrgreenstuff/acl-sonata-admin-extension-bundle.

I will write a clear DOC soon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants