alchemy / acl-bundle
Symfony ACL bundle
Requires
- php: ^8.5
- ext-json: *
- doctrine/orm: ^2.6|^3.6.1
- ramsey/uuid-doctrine: ^1.5|^2.1.0
- symfony/event-dispatcher: ^6.4|^7.4
- symfony/framework-bundle: ^6.4|^7.4
- symfony/security-bundle: ^6.4|^7.4
- symfony/validator: ^6.4|^7.4
- symfony/yaml: ^6.4|^7.4
Requires (Dev)
- doctrine/doctrine-bundle: ^2.10
- friendsofphp/php-cs-fixer: ^3
- phpunit/phpunit: ^8.4|^10.2.2
- rector/rector: ^2.0.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Lightweight access control lists for Symfony applications.
The bundle stores access control entries (ACEs) in a single Doctrine table. An ACE grants a bitmask of permissions to a user or a group, on one object or on every object of a type. It ships with a Symfony voter, a service to check and manage permissions, a helper to filter Doctrine queries by ACL, an HTTP API and an optional EasyAdmin UI.
Table of contents
- Requirements
- Installation
- Configuration
- Making your entities ACL-aware
- Permissions
- Checking permissions
- Managing ACEs from PHP
- Filtering Doctrine queries
- Events
- HTTP API
- Admin UI (EasyAdmin)
- Development
- License
Requirements
| Dependency | Version |
|---|---|
| PHP | 8.5 |
| Symfony | 6.4 or 7.4 |
| Doctrine ORM | 2.6 or 3.6 |
Installation
composer require alchemy/acl-bundle
Register the bundle if Flex did not do it for you:
// config/bundles.php return [ // ... Alchemy\AclBundle\AlchemyAclBundle::class => ['all' => true], ];
The AccessControlEntry entity uses the uuid DBAL type from ramsey/uuid-doctrine.
Register it once and let Doctrine's auto_mapping discover the entity:
# config/packages/doctrine.yaml doctrine: dbal: types: uuid: Ramsey\Uuid\Doctrine\UuidType orm: auto_mapping: true
Then create the table:
bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate
Finally import the API routes (the prefix is up to you):
# config/routes/alchemy_acl.yaml alchemy_acl_api: resource: '@AlchemyAclBundle/config/routing/permissions_api.yaml' prefix: /permissions
Configuration
Objects
Declare the entities you want to protect. The key is the objectType used everywhere
(database, API, events), the value is the entity class. Subclasses and Doctrine proxies
of a mapped class resolve to the same objectType.
# config/packages/alchemy_acl.yaml alchemy_acl: objects: publication: App\Entity\Publication asset: App\Entity\Asset # Optional: the permissions offered by the admin UI. # Default: [VIEW, CREATE, EDIT, DELETE, OPERATOR, OWNER] enabled_permissions: [VIEW, EDIT, DELETE]
User and group repositories
The bundle knows nothing about your users. Provide two services by aliasing the bundle interfaces to your own implementations:
# config/services.yaml services: Alchemy\AclBundle\Repository\UserRepositoryInterface: '@App\Repository\UserRepository' Alchemy\AclBundle\Repository\GroupRepositoryInterface: '@App\Repository\GroupRepository'
namespace Alchemy\AclBundle\Repository; interface UserRepositoryInterface { /** @return array<array{id: string, username: string}> */ public function getUsers(array $options = []): array; /** @return array{id: string, username: string}|null */ public function getUser(string $userId, array $options = []): ?array; /** @return string[] The IDs of the groups the user belongs to */ public function getAclGroupsId(AclUserInterface $user): array; } interface GroupRepositoryInterface { /** @return array<array{id: string, name: string}> */ public function getGroups(array $options = []): array; /** @return array{id: string, name: string}|null */ public function getGroup(string $groupId, array $options = []): ?array; }
getAclGroupsId() is called on every permission check, so make it cheap (cache it if
needed). The other methods are only used by the API serializer and the admin UI.
Making your entities ACL-aware
Protected entities implement AclObjectInterface. The owner returned by getAclOwnerId()
is granted everything on the object without any ACE.
use Alchemy\AclBundle\AclObjectInterface; class Publication implements AclObjectInterface { public function getId(): string { return $this->id->toString(); } public function getAclOwnerId(): string { return $this->owner->getId(); } }
Your security user implements AclUserInterface in addition to Symfony's UserInterface:
use Alchemy\AclBundle\Model\AclUserInterface; use Symfony\Component\Security\Core\User\UserInterface; class User implements UserInterface, AclUserInterface { public function getId(): string { return $this->id; } }
Permissions
Permissions are bits. An ACE stores their sum in its mask. The constants live in
Alchemy\AclBundle\Security\PermissionInterface:
| Name | Value | Name | Value |
|---|---|---|---|
VIEW |
1 | CHILD_VIEW |
512 |
CREATE |
2 | CHILD_CREATE |
1024 |
EDIT |
4 | CHILD_EDIT |
2048 |
DELETE |
8 | CHILD_DELETE |
4096 |
UNDELETE |
16 | CHILD_UNDELETE |
8192 |
OPERATOR |
32 | CHILD_OPERATOR |
16384 |
MASTER |
64 | CHILD_MASTER |
32768 |
OWNER |
128 | CHILD_OWNER |
65536 |
SHARE |
256 | CHILD_SHARE |
131072 |
Permissions are independent: EDIT does not imply VIEW. A mask of 7 grants
VIEW, CREATE and EDIT. The CHILD_* permissions have no built-in behaviour; they
are here for applications that want to express rights on children of an object.
Wildcards
- All users: an ACE with a
NULLuserIdapplies to everybody. Through the API and the admin UI, sendAccessControlEntryInterface::USER_WILDCARD(__ALL_USERS__) as theuserId. - All objects of a type: an ACE with a
NULLobjectIdapplies to every object of itsobjectType.
How a check is resolved
For a user, an object and a permission, access is granted if any of the following holds:
- the user is the owner of the object (see
isGranted(..., ownershipGrants: false)to disable this); - an ACE carrying the permission targets the user, one of the user's groups, or all users, on this object or on the whole object type.
Checking permissions
With the Symfony voter
AclVoter votes on any AclObjectInterface when the attribute is a numeric permission:
use Alchemy\AclBundle\Security\PermissionInterface; // In a controller $this->denyAccessUnlessGranted((string) PermissionInterface::EDIT, $publication); // With the attribute #[IsGranted('4', subject: 'publication')] public function edit(Publication $publication): Response
{% if is_granted('4', publication) %}
<a href="{{ path('publication_edit', {id: publication.id}) }}">Edit</a>
{% endif %}
With the PermissionManager
use Alchemy\AclBundle\Security\PermissionManager; use Alchemy\AclBundle\Security\PermissionInterface; public function __construct(private PermissionManager $permissionManager) {} // One permission $this->permissionManager->isGranted($user, $publication, PermissionInterface::EDIT); // Any of several permissions $this->permissionManager->isGranted($user, $publication, [PermissionInterface::EDIT, PermissionInterface::OPERATOR]); // Ignore ownership, rely on ACEs only $this->permissionManager->isGranted($user, $publication, PermissionInterface::EDIT, ownershipGrants: false); // Who can see this object? $userIds = $this->permissionManager->getAllowedUsers($publication, PermissionInterface::VIEW); $groupIds = $this->permissionManager->getAllowedGroups($publication, PermissionInterface::VIEW);
The manager caches the ACEs it loads per user and object for the lifetime of the request.
Writing through the manager invalidates the relevant entry; call resetCache() if you
change ACEs by other means (long-running workers, direct SQL...).
Managing ACEs from PHP
use Alchemy\AclBundle\Model\AccessControlEntryInterface; use Alchemy\AclBundle\Security\PermissionInterface; // Grant a user or a group on one object (replaces the existing mask) $permissionManager->grantUserOnObject($userId, $publication, PermissionInterface::VIEW | PermissionInterface::EDIT); $permissionManager->grantGroupOnObject($groupId, $publication, PermissionInterface::VIEW); // Full control: object type wide, append to the existing mask, attach metadata $permissionManager->updateOrCreateAce( AccessControlEntryInterface::TYPE_GROUP_VALUE, $groupId, 'publication', null, // every publication PermissionInterface::VIEW, metadata: ['granted_by' => $adminId], append: true, // keep the bits already set ); // Remove an ACE $permissionManager->deleteAce(AccessControlEntryInterface::TYPE_USER_VALUE, $userId, 'publication', $publication->getId()); // Read $permissionManager->getAces($user, $publication); // ACEs that apply to this user on this object $permissionManager->getObjectAces($publication); // every ACE on this object, plus type-wide ones
An optional parentId lets you keep several ACEs for the same user and object, one per
"source" (for instance the collection the object was shared through). The unique
constraint is on (userType, userId, objectType, objectId, parentId).
Automatic cleanup
AclObjectDeleteListener listens to Doctrine's postRemove event and deletes every ACE
of a mapped object when the object is removed.
Filtering Doctrine queries
To list only the objects a user can see, join the ACL table with the static helper:
use Alchemy\AclBundle\Entity\AccessControlEntryRepository; use Alchemy\AclBundle\Security\PermissionInterface; $qb = $this->createQueryBuilder('p'); AccessControlEntryRepository::joinAcl( $qb, $user->getId(), $userRepository->getAclGroupsId($user), 'publication', // objectType 'p', // alias of the protected entity in the query PermissionInterface::VIEW, );
The helper adds an INNER JOIN (pass inner: false for a LEFT JOIN) that matches ACEs
on the object or on the whole type, for the user, their groups or all users, and having
the requested permission bits. The aceAlias and paramPrefix arguments let you join the
ACL table several times in one query.
Events
Every write through the PermissionManager (including the HTTP API) dispatches an event
with the previous state of the ACE, so you can audit changes or refresh caches:
| Event | Name | Extra getters |
|---|---|---|
Alchemy\AclBundle\Event\AclUpsertEvent |
acl.upsert |
getPermissions(), getMetadata() |
Alchemy\AclBundle\Event\AclDeleteEvent |
acl.delete |
Both expose getUserType(), getUserId(), getObjectType(), getObjectId(),
getPreviousPermissions() and getPreviousMetadata(). The previous values are null
when the ACE did not exist before.
use Alchemy\AclBundle\Event\AclUpsertEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: AclUpsertEvent::NAME)] public function onAclUpsert(AclUpsertEvent $event): void { // ... }
HTTP API
The examples below assume the routes were imported under /permissions.
Fields
| Field | Description |
|---|---|
userType |
user or group |
userId |
The user or group ID. Send __ALL_USERS__ to target everybody (stored as NULL). |
objectType |
One of the keys declared in alchemy_acl.objects |
objectId |
The object ID. null or empty targets every object of the type. |
mask |
The sum of the granted permission bits |
metadata |
Free JSON object stored with the ACE (optional) |
parentId |
Optional discriminator, see Managing ACEs from PHP |
Responses are serialized ACEs. When the ACE targets a specific user or group, the payload
also contains a user or group key filled by your repositories.
GET /permissions/aces
Lists ACEs. Filters are passed as query parameters: userType, userId, objectType,
objectId. An empty value or the string null matches NULL (for instance
objectId=null returns type-wide ACEs). Add userIdWildcard=1 or objectIdWildcard=1 to
also include the wildcard ACEs alongside the ones matching the given ID.
# ACEs of an object, including type-wide ones curl "{HOST}/permissions/aces?objectType=publication&objectId=pub-42&objectIdWildcard=1" # ACEs of a group curl "{HOST}/permissions/aces?userType=group&userId=g-42" # ACEs of a user on an object curl "{HOST}/permissions/aces?userType=user&userId=u-42&objectType=publication&objectId=pub-42"
PUT /permissions/ace
Creates or replaces an ACE. The body can be JSON or form-encoded.
{
"userType": "user",
"userId": "u-42",
"objectType": "publication",
"objectId": "pub-42",
"mask": 7,
"metadata": {"granted_by": "admin"}
}
DELETE /permissions/ace
{
"userType": "user",
"userId": "u-42",
"objectType": "publication",
"objectId": "pub-42"
}
Authorization
ROLE_ADMINcan do everything.- Otherwise the application's voters are asked for
ACL_READ(list) orACL_WRITE(create, update, delete). The bundledSetPermissionVotergrants both to the owner of the object. - When
objectIdis empty there is no object to vote on: the subject is anAlchemy\AclBundle\Security\ObjectTypeSubjectcarrying theobjectTypeand its class. The bundle grants nothing on it by default, so write a voter if non-admins must manage type-wide ACEs:
use Alchemy\AclBundle\Security\ObjectTypeSubject; use Alchemy\AclBundle\Security\Voter\SetPermissionVoter; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; class ObjectTypeAclVoter extends Voter { protected function supports(string $attribute, mixed $subject): bool { return SetPermissionVoter::ACL_WRITE === $attribute && $subject instanceof ObjectTypeSubject; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { return 'publication' === $subject->objectType && in_array('ROLE_PUBLICATION_MANAGER', $token->getRoleNames(), true); } }
Requests with an unknown objectType or an invalid userType get a 400, denied requests
a 403.
Admin UI (EasyAdmin)
When EasyAdminBundle is installed the bundle prepends an AccessControlEntry entity to
the EasyAdmin configuration, so ACEs can be browsed and edited like any other entity.
The bundle also ships templates to manage the permissions of one object or of a whole
type with a checkbox matrix (@AlchemyAcl/permissions/entity/acl.html.twig and
@AlchemyAcl/permissions/global/acl.html.twig). They call the API through the admin
routes, which you import next to your admin area:
# config/routes/alchemy_acl_admin.yaml alchemy_acl_admin: resource: '@AlchemyAclBundle/config/routing/permissions_admin.yaml' prefix: /admin/permissions
Render them from your own admin controller with the parameters built by PermissionView:
use Alchemy\AclBundle\Admin\PermissionView; public function permissions(PermissionView $view, string $type, ?string $id = null): Response { return $this->render( null === $id ? '@AlchemyAcl/permissions/global/acl.html.twig' : '@AlchemyAcl/permissions/entity/acl.html.twig', $view->getViewParameters($type, $id), ); }
The checkboxes shown are the ones listed in alchemy_acl.enabled_permissions.
Development
Install the dependencies and run the test suite:
composer install
composer test
Code style and refactoring rules:
composer cs # php-cs-fixer composer rector # rector
Without a local PHP 8.5, the same commands run in Docker:
docker run --rm -v "$PWD":/app -w /app php:8.5-cli vendor/bin/phpunit
The suite is made of unit tests only: the Doctrine repository, the entity manager and the security layer are replaced by mocks, so no database is needed.
License
MIT