How do one use ACL to filter a list of domain-objects according to a certain user's permissions (e.g. EDIT)?
Assuming that you have a collection of domain objects that you want to check, you can use the security.acl.provider
service's findAcls()
method to batch load in advance of the isGranted()
calls.
Conditions:
Database was populated with test entities, with object permissions of MaskBuilder::MASK_OWNER
for a random user from my database, and class permissions of MASK_VIEW
for role IS_AUTHENTICATED_ANONYMOUSLY
; MASK_CREATE
for ROLE_USER
; and MASK_EDIT
and MASK_DELETE
for ROLE_ADMIN
.
Test Code:
$repo = $this->getDoctrine()->getRepository('Foo\Bundle\Entity\Bar');$securityContext = $this->get('security.context');$aclProvider = $this->get('security.acl.provider');$barCollection = $repo->findAll();$oids = array();foreach ($barCollection as $bar) { $oid = ObjectIdentity::fromDomainObject($bar); $oids[] = $oid;}$aclProvider->findAcls($oids); // preload Acls from databaseforeach ($barCollection as $bar) { if ($securityContext->isGranted('EDIT', $bar)) { // permitted } else { // denied }}
RESULTS:
With the call to $aclProvider->findAcls($oids);
, the profiler shows that my request contained 3 database queries (as anonymous user).
Without the call to findAcls()
, the same request contained 51 queries.
Note that the findAcls()
method loads in batches of 30 (with 2 queries per batch), so your number of queries will go up with larger datasets. This test was done in about 15 minutes at the end of the work day; when I have a chance, I'll go through and review the relevant methods more thoroughly to see if there are any other helpful uses of the ACL system and report back here.
Itinerating over the entities is not feasible if you have a couple of thousandth entities - it will keep getting slower and consuming more memory, forcing you to use doctrine batching capabilities, thus making your code more complex (and innefective because after all you need only the ids to make a query - not the whole acl/entities in memory)
What we did to solve this problem is to replace acl.provider service with our own and in that service add a method to make a direct query to the database:
private function _getEntitiesIdsMatchingRoleMaskSql($className, array $roles, $requiredMask){ $rolesSql = array(); foreach($roles as $role) { $rolesSql[] = 's.identifier = ' . $this->connection->quote($role); } $rolesSql = '(' . implode(' OR ', $rolesSql) . ')'; $sql = <<<SELECTCLAUSE SELECT oid.object_identifier FROM {$this->options['entry_table_name']} e JOIN {$this->options['oid_table_name']} oid ON ( oid.class_id = e.class_id ) JOIN {$this->options['sid_table_name']} s ON ( s.id = e.security_identity_id ) JOIN {$this->options['class_table_nambe']} class ON ( class.id = e.class_id ) WHERE {$this->connection->getDatabasePlatform()->getIsNotNullExpression('e.object_identity_id')} AND (e.mask & %d) AND $rolesSql AND class.class_type = %s GROUP BY oid.object_identifier SELECTCLAUSE; return sprintf( $sql, $requiredMask, $this->connection->quote($role), $this->connection->quote($className) );}
Then calling this method from the actual public method that gets the entities ids:
/** * Get the entities Ids for the className that match the given role & mask * * @param string $className * @param string $roles * @param integer $mask * @param bool $asString - Return a comma-delimited string with the ids instead of an array * * @return bool|array|string - True if its allowed to all entities, false if its not * allowed, array or string depending on $asString parameter. */public function getAllowedEntitiesIds($className, array $roles, $mask, $asString = true){ // Check for class-level global permission (its a very similar query to the one // posted above // If there is a class-level grant permission, then do not query object-level if ($this->_maskMatchesRoleForClass($className, $roles, $requiredMask)) { return true; } // Query the database for ACE's matching the mask for the given roles $sql = $this->_getEntitiesIdsMatchingRoleMaskSql($className, $roles, $mask); $ids = $this->connection->executeQuery($sql)->fetchAll(\PDO::FETCH_COLUMN); // No ACEs found if (!count($ids)) { return false; } if ($asString) { return implode(',', $ids); } return $ids;}
This way now we can use the code to add filters to DQL queries:
// Some action in a controller or form handler...// This service is our own aclProvider version with the methods mentioned above$aclProvider = $this->get('security.acl.provider');$ids = $aclProvider->getAllowedEntitiesIds('SomeEntityClass', array('role1'), MaskBuilder::VIEW, true);if (is_string($ids)) { $queryBuilder->andWhere("entity.id IN ($ids)");}// No ACL found: deny allelseif ($ids===false) { $queryBuilder->andWhere("entity.id = 0")}elseif ($ids===true) { // Global-class permission: allow all}// Run query...etc
Drawbacks: This methods have to be improved to take into account the complexities of ACL inheritance and strategies, but for simple use cases it works fine. Also a cache has to be implemented to avoid the repetitive double query (one with class-level, another with objetc-level)
Coupling Symfony ACL back to application and using it as sorting, is not good approach. You are mixing and coupling 2 or 3 layers of application together.ACL functionality is to answer "YES/NO" to question "Am I allowed to do this?" If you need some sort of owned/editable articles, you can use some column like CreatedBy or group CreatedBy by criteria from another table. Some usergroups or accounts.