This package implements dynamic Access Control Lists for Neos Roles.
The development of this package was sponsored by ujamii and queo.
Main features:
- Switch
RestrictedEditorto an allowlist-only permission approach. By installing this package, theRestrictedEditoris no longer allowed to change any content. - Configure dynamic roles through a Neos backend module.
- Permissions on the node tree, workspaces and dimensions possible.
- Permissions work predictably with sane defaults and purely additive logic.
- Install the package:
composer require sandstorm/neosacl
- Run the migrations
./flow doctrine:migrate
- Log in with an admin account and visit the new menu entry 'Dynamic Roles'
Initial (Package) Setup
- Clone this package as
Sandstorm.NeosAclin the DistributionPackages of a Neos 4.3 or later installation - Add it to
composer.jsonas"sandstorm/neosacl": "*" - Run
composer update
Initial React Setup
cd Resources/Private/react-acl-editor
yarn
yarn dev
Then, log into the backend of Neos, and visit the module "Dynamic Roles".
The basic idea was the following: Hook into PolicyService::emitConfigurationLoaded, and modify the $configuration array (introduce new roles
and privilegeTargets). This basically works at runtime - however there is a problem with dynamic MethodPrivilege enforcement, which is
explained below and by the following diagram:
- Background: An implementation of
PointcutFilterInterfacecan - during compile time of Flow - decide which classes and methods match for a certain aspect.- This is used in
PolicyEnforcementAspect(which is the central point for enforcing MethodPrivileges). - There, the
MethodPrivilegePointcutFilteris referenced. - The
MethodPrivilegePointcutFilterasks thePolicyServicefor all configuredMethodPrivileges - and ensures AOP proxies are built for these methods.
- This is used in
- Side Effect: Now, during building up the pointcut filters, the
MethodPrivilegePointcutFilteradditionally builds up a data structuremethodPermissions- which remembers whichMethodPrivilegesare registered for which method.- This data structure is stored persistently in the
Flow_Security_Authorization_Privilege_Methodcache. - At runtime, for a class which is intercepted by
PolicyEnforcementAspect, all configuredMethodPrivileges are invoked - and they have to quickly decide if they match this particular call-site. - This is done using the
methodPermissionsdata structure from theFlow_Security_Authorization_Privilege_Methodcache.
- This data structure is stored persistently in the
- If a
MethodPrivilegeis defined dynamically at runtime, then themethodPermissionsdata structure is missing the information that this new privilege should be invoked for certain methods. - NOTE: You can only dynamically add
MethodPrivilegesfor call-sites which are already instrumented by AOP; because otherwise the code will never get invoked (because of missing proxies).
We are mostly working with EditNodePrivilege etc. - so why does this apply there?
EditNodePrivilegehas an internalMethodPrivilegewhich takes care of the method call enforcement part; i.e. preventing you to call e.g.NodeInterface::setProperty()if you do not have the permission to do so.
Furthermore, to make this idea work, the Policy.yaml of this package defines a catch-all Sandstorm.NeosAcl:EditAllNodes
PrivilegeTarget - so AOP will instrument the corresponding methods of NodeInterface. This catch-all makes sense
in any case, because this switches the security framework to an allowlist-only approach
- making it easier to grasp.
In order to make the dynamic policy enforcement work, we need to add custom stuff to the methodPermissions - for
the dynamically added roles.
The post-processing of the methodPermissions is done using a custom cache frontend (SecurityAuthorizationPrivilegeMethodCacheFrontend).
Method privileges internally can use dynamic AOP Runtime Expressions (in case you check for method parameters). Especially
the MethodPrivilege - which is attached to the RemoveNodePrivilege - uses the following expression code:
return 'within(' . NodeInterface::class . ') && method(.*->setRemoved(removed == true))';The removed == true part is a so-called AOP Runtime Expression.
This is internally implemented using the Flow_Aop_RuntimeExpressions "cache", which is pre-filled again during the compile
time (which is a nasty side-effect).
Thus, in our case we need to again implement a custom cache frontend (AopRuntimeExpressionsCacheFrontend),
using the runtime expressions of the base configuration, which exists properly.

