tilleuls/acl-sonata-admin-extension-bundle
A Symfony bundle that extends Sonata Admin with ACL support, letting you control access to admin sections, actions, and objects through Symfony's ACL system for fine-grained, per-object permissions.
This is a small, single-purpose bundle: it registers a global Sonata Admin extension that rewrites each admin's list query (configureQuery()) so the datagrid only shows objects on which the current user has a given ACL permission (typically VIEW). If an object has no ACE for the user, it does not appear in the list. That is the whole feature — everything else is Symfony ACL setup.
Minimal steps:
composer require tilleuls/acl-sonata-admin-extension-bundle (last release 2017 — expect to stay on Symfony 2.8/3.x + Sonata Admin 3.x; on Symfony 3+ also require symfony/security-acl explicitly).CoopTilleuls\Bundle\AclSonataAdminExtensionBundle\CoopTilleulsAclSonataAdminExtensionBundle in AppKernel after SonataAdminBundle and SecurityBundle.security.acl.provider:
security:
acl:
connection: default
$admin->isGranted('VIEW', $object) must return the expected result for a test object. The bundle filters the list; it does not replace object-level voters.ArticleAdmin), create one Article, grant a non-admin user MASK_VIEW on it, log in as that user, and check that the list contains exactly that row. If it is empty, the ACL was never created — see the gotcha below.Where to look first: Admin/ (the extension class and its configureQuery implementation), DependencyInjection/ + Resources/config/services.xml (the sonata.admin.extension tag, global flag and target), then the README for the permission/config knob. The code is small enough to read end-to-end before trusting it.
Create ACEs at write time, not read time. The bundle only reads ACLs; you must populate them. A Doctrine postPersist listener is the canonical place:
$oid = ObjectIdentity::fromDomainObject($entity);
$acl = $aclProvider->createAcl($oid);
// owner gets full control
$acl->insertObjectAce($entity->getOwner(), MaskBuilder::MASK_OWNER);
// a role gets read access
$acl->insertObjectAce(new RoleSecurityIdentity('ROLE_EDITOR'), MaskBuilder::MASK_VIEW);
$aclProvider->updateAcl($acl);
Wrap this in a transaction with the entity flush, and guard against re-creating the ACL (findAcl in a try/catch on AclNotFoundException).
Use the owner pattern for user-generated content. Store an owner on the entity, insert a MASK_OWNER ACE for that user, and insert MASK_VIEW/MASK_EDIT for roles. This keeps ACL rows small and makes "my articles" lists work without per-user grants.
Keep class-level ACLs for staff. Grant VIEW on the class object identity (new ObjectIdentity('class', Article::class)) to ROLE_ADMIN. Admins then see everything without creating an ACE per row; per-object ACLs remain for non-admin users. Check whether the bundle short-circuits on a class-level grant before you rely on this — verify with a small dataset.
Disable per admin when needed. The extension is registered globally. For admins that must never be filtered (audit logs, reference data, dashboards), either override the service definition with a guard in a subclass, or register your own sonata.admin.extension with a narrower target. Prefer the guard: it is one if in configureQuery() and survives bundle updates.
Respect extension ordering. If another extension joins the root alias or replaces it, ACL filtering (which typically adds id IN (SELECT object_identifier …)) must run against the same root alias. Control this with the priority attribute on sonata.admin.extension.
Treat it as list filtering only. Show/edit/delete still go through Sonata's isGranted() checks and the ACL voter. Do not assume a filtered list means the actions are protected; test direct URLs.
SELECT * FROM acl_object_identities WHERE object_identifier = '<id>' and SELECT * FROM acl_entries WHERE object_identity_id = <oid>.ROLE_SUPER_ADMIN; make sure your ACL filtering does the same or your super admin sees an empty list. Test with the real production role hierarchy.IN (...) or subquery. On tables with tens of thousands of rows, keep the subquery scoped by class_id and index acl_entries(object_identity_id, security_identity_id) and acl_object_identities(class_id, object_identifier). Watch for implicit string→int casts: object_identifier is a string, so comparing to an integer column can kill the index in MySQL.acl_entries. Invalidate after changing ACEs.getCount() from the same query, so counts should match the filter. If an admin overrides getCount() or uses a custom data source, filtering silently breaks — check those admins.ModelType, sonata_type_model_list), go through different queries.batchAction operate on the filtered set before shipping.AbstractAdminExtension::configureQuery() backed by your own "viewable IDs" service (voter + repository query), or Symfony voters with a repository-level filter. The extension point itself is one method, so the migration is mechanical once you have an ACL source of truth.How can I help you explore Laravel packages today?