coopTilleuls / CoopTilleulsAclSonataAdminExtensionBundle

ACL list filtering for SonataAdmin
http://les-tilleuls.coop
MIT License
45 stars 21 forks source link

Filter child entity from parent ACL #6

Closed MrGreenStuff closed 10 years ago

MrGreenStuff commented 10 years ago

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 commented 10 years ago

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 commented 10 years ago

@dunglas @MrGreenStuff Yep, we've that sample code here: http://www.sroze.io/2014/01/08/sonata-admin-create-acl-on-object-created-outside-of-admin/

MrGreenStuff commented 10 years ago

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

MrGreenStuff commented 10 years ago

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

3 Users :

Behavior expected :

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

<?php

/*
 * (c) La Coopérative des Tilleuls <contact@les-tilleuls.coop>
 *
 * 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 <kevin@les-tilleuls.coop>
 */
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 commented 10 years ago

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 <contact@les-tilleuls.coop>
 *
 * 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 <kevin@les-tilleuls.coop>
 */
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 commented 10 years ago

I made a fork with this enhancements here : MrGreenStuffAclSonataAdminExtensionBundle.

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

I will write a clear DOC soon