After a lot of effort I was finally able to configure Sonata Admin with ACL following this guide:
https://sonata-project.org/bundles/admin/master/doc/reference/security.html
I wanted to users to be able to view and edit only items with the same country property as the user.
This is my config.yml:
parameters:
locale: en
sonata.user.admin.user.class: AppBundle\Admin\UserAdmin
sonata.admin.security.mask.builder.class: Sonata\AdminBundle\Security\Acl\Permission\MaskBuilder
# SonataAdminBundle Configuration
sonata_admin:
security:
handler: sonata.admin.security.handler.acl
role_admin: ROLE_ADMIN
role_super_admin: ROLE_SUPER_ADMIN
# acl security information
information:
GUEST: [VIEW, LIST]
STAFF: [EDIT, LIST, CREATE]
EDITOR: [OPERATOR, EXPORT]
ADMIN: [MASTER]
# permissions not related to an object instance and also to be available when objects do not exist
# the DELETE admin permission means the user is allowed to batch delete objects
admin_permissions: [CREATE, LIST, DELETE, UNDELETE, EXPORT, OPERATOR, MASTER]
# permission related to the objects
object_permissions: [VIEW, EDIT, DELETE, UNDELETE, OPERATOR, MASTER, OWNER]
I created an AclVoter in order to show/hides elements:
services:
security.acl.voter.country_owned_permissions:
class: AppBundle\Security\Authorization\Voter\CountryOwnedAclVoter
arguments:
- "#security.acl.provider"
- "#security.acl.object_identity_retrieval_strategy"
- "#security.acl.security_identity_retrieval_strategy"
- "#security.acl.permission.map"
- "#logger"
tags:
- { name: monolog.logger, channel: security }
- { name: security.voter, priority: 255 }
public: false
This is the actual class:
<?php
namespace AppBundle\Security\Authorization\Voter;
use FOS\UserBundle\Model\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Acl\Voter\AclVoter;
class CountryOwnedAclVoter extends AclVoter
{
public function supportsClass($class)
{
// support the Class-Scope ACL for votes with the custom permission map
// return $class === 'Sonata\UserBundle\Admin\Entity\UserAdmin' || is_subclass_of($class, 'FOS\UserBundle\Model\UserInterface');
// if you use php >=5.3.7 you can check the inheritance with is_a($class, 'Sonata\UserBundle\Admin\Entity\UserAdmin');
// support the Object-Scope ACL
return is_subclass_of($class, 'AppBundle\Model\CountryOwnedInterface');
}
public function supportsAttribute($attribute)
{
return in_array($attribute, array(
'LIST',
'VIEW',
'EDIT',
'DELETE',
'EXPORT',
));
}
public function vote(TokenInterface $token, $object, array $attributes)
{
if (!$this->supportsClass(get_class($object))) {
return self::ACCESS_ABSTAIN;
}
foreach ($attributes as $attribute) {
if ($this->supportsAttribute($attribute)) {
if ($object->getCountry() != $token->getUser()->getCountry()) {
//if ($object->isSuperAdmin() && !$token->getUser()->isSuperAdmin()) {
// deny a non super admin user to edit a super admin user
return self::ACCESS_DENIED;
}
}
}
// use the parent vote with the custom permission map:
// return parent::vote($token, $object, $attributes);
// otherwise leave the permission voting to the AclVoter that is using the default permission map
return self::ACCESS_ABSTAIN;
}
}
It seems to work fine since a user can only edit items which have the same country as the users. The problem is that he can still view the items in the list.
What am I doing wrong?
As specified in the official documentation I just needed to install a specific bundle:
5.4.6. LIST FILTERING
List filtering using ACL is available as a third party bundle: CoopTilleulsAclSonataAdminExtensionBundle. When enabled,
the logged in user will only see the objects for which it has the VIEW
right (or superior).
This will suffice:
composer require tilleuls/acl-sonata-admin-extension-bundle
In AppKernel.php:
// ACL list filter
new CoopTilleuls\Bundle\AclSonataAdminExtensionBundle\CoopTilleulsAclSonataAdminExtensionBundle(),
Related
I come from Magento and I now use Sylius to modernize things a bit and have access to a less xml oriented platform because I find it really painful in 2022...
Unfortunately I did not find anything in Sylius regarding the management of prices according to the client currently logged.
So I want to use groups and channels: I added a channel relation to a user group to be able to use a channel according to the logged in user.
/**
* #ORM\Entity
* #ORM\Table(name="sylius_customer_group")
*/
class CustomerGroup extends BaseCustomerGroup
{
/**
* #ORM\ManyToOne(targetEntity=Channel::class)
*/
private $channel;
public function getChannel(): ?Channel
{
return $this->channel;
}
public function setChannel(?Channel $channel): self
{
$this->channel = $channel;
return $this;
}
}
Here is what I am trying to do with a service
services:
ChangingContextWithCustomerGroup:
class: App\Context\RequestQueryChannelContext
arguments:
- '#sylius.repository.channel'
- '#request_stack'
- '#sylius.context.customer'
tags:
- { name: sylius.context.channel, priority: 150 }
// src/Context/RequestQueryChannelContext.php
public function getChannel(): ChannelInterface
{
$request = $this->requestStack->getMainRequest();
if (!$request) {
throw new ChannelNotFoundException('Request Not Found!');
}
$customer = $this->customerContext->getCustomer();
if (!$customer instanceof Customer) {
throw new ChannelNotFoundException('Customer Not Found!');
}
$group = $customer->getGroup();
if (!$group instanceof CustomerGroup) {
throw new ChannelNotFoundException('Group Not Found!');
}
$channel = $group->getChannel();
if (!$channel instanceof ChannelInterface) {
throw new ChannelNotFoundException('Channel Not Found!');
}
return $channel;
}
My problem is that I can't get the customer on the mainRequest. It is null, so I cant have the customer => group => channel.
It works very well when I force the channel like this :
public function getChannel(): ChannelInterface
{
// ...
return $this->channelRepository->findOneByCode('fooBar');
}
so my system doesn't work. Is there a better solution?
thanks
The problem here is that the Channel context is called from the Sylius\Bundle\ShopBundle\EventListener\NonChannelLocaleListener which has priority 10 on the kernel.request event. In the channel context you want to use the customer context class, which in turn uses the TokenStorage to retrieve the user information.
The token however is not yet populated in the token storage at that point, because that happens in the firewall listener (Symfony\Bundle\SecurityBundle\Debug\TraceableFirewallListener in dev environments), which has priority 8.
The solution I found is to lower the priority of the NonChannelLocaleListener to 7. That will make sure that the token is available and that the customer context can be used to retrieve the customer/shop user information.
Lowering the priority of that listener can be done by overriding the service definition in config/services.yaml:
services:
...
sylius.listener.non_channel_request_locale:
class: Sylius\Bundle\ShopBundle\EventListener\NonChannelLocaleListener
arguments:
- '#router'
- '#sylius.locale_provider'
- '#security.firewall.map'
- ['%sylius_shop.firewall_context_name%']
tags:
- { name: kernel.event_listener, event: kernel.request, method: restrictRequestLocale, priority: 7 }
Please note that this is on Sylius 1.10, so it might be possible that on other Sylius versions the priority of these listeners are slightly different. In that case, just use the bin/console debug:event-dispatcher command to figure out what the right priority should be.
Suppose I have two authentication mechanisms. How do I allow/deny a user's access to content depending on which authentication mechanism they used to authenticate themselves.
For example, let's say I have:
two type of users: admin and user such that all admins are users but users are not admins
two authentication mechanisms: admin_login and user_login
I would like to make it so that an admin has to authenticate through admin_login in order to have admin accesses. This means he would be considered a regular user if he was authenticated through user_login.
I first thought about using firewall context, but quickly realized it wasn't gonna work as I would need a firewall to support multiple contexts :
# config/packages/security.yaml
security:
# ...
firewalls:
admin:
# ...
context: // not currently supported
- admin
- user
user:
# ...
context: user
The other idea I came up with was creating a property called is_allow_admin in the User class and using it to change the way the roles are retrieved by setting it in the admin_login authenticator :
// src/Entity/User.php
// ...
class User implements UserInterface
{
// ...
private $is_allow_admin = false;
// ...
public function setIsAllowAdmin(bool $is_allow_admin)
{
$this->is_allow_admin = $is_allow_admin ;
}
public function getRoles(): array
{
if (!$this->is_allow_admin) {
return ['ROLE_USER'];
}
//...
return $roles;
}
// ...
}
// src/Security/AdminAuthenticator.php
class AdminAuthenticator extends AbstractFormLoginAuthenticator
{
//...
public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
{
$user = $userProvider->loadUserByUsername($credentials['username']);
if (!$user) {
throw new CustomUserMessageAuthenticationException('username could not be found.');
}
$user->setIsAllowAdmin(true); // <-
return $user;
}
//...
}
This unfortunately doesn't work. Everything goes smoothly with the authenticator, onAuthenticationSuccess is triggered. But somehow in the end, no user is authenticated.
I know it has to do with $this->setIsAllowAdmin(true); since it works correctly when I remove the line.
Is there another way to tackle this problem?
Thank you in advance.
I created a custom module to create a /store/ID/tasks page
https://www.drupal.org/project/commerce
How to limit access to this page to the store owner ?
If the current user is owner of store ID 76, he can access this page :
/store/76/tasks
But if he goes to another store, he must have denied access :
/store/89/tasks
https://git.drupalcode.org/sandbox/zenimagine-3076032
task_notify/task_notify.routing.yml
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
task_notify/src/Controller/TaskNotifyStoreController.php
<?php
namespace Drupal\task_notify\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\commerce_store\Entity\StoreInterface;
class TaskNotifyStoreController extends ControllerBase {
public function Tasks() {
return [
'#theme' => 'task_notify_store_template',
];
}
public function taskAccess(StoreInterface $store, AccountInterface $account = NULL, $return_as_object = FALSE) {
$result = $store->access('edit', $account, TRUE);
return $return_as_object ? $result : $result->isAllowed();
}
}
This page should be accessible only if the current user can edit the store (the site administrator and the store owner).
Access in the module code must have the same conditions as in this view :
https://i.stack.imgur.com/ZfUMo.png
I was inspired by the two files below :
https://git.drupalcode.org/project/commerce_marketplace/-/blob/8.x-1.x/src/Plugin/Action/MarketplaceIncreaseStoreLimitByOne.php
https://git.drupalcode.org/project/commerce_marketplace/-/blob/8.x-1.x/src/Plugin/Action/MarketplaceMarkAsDefault.php
In this case, we can tell Drupal that {store} is an entity and it will load the object. So we don't have to do that in the Controller function.
So your routing file can include "parameters" settings to do that.
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
options:
parameters:
store:
type: entity:commerce_store
Now your controller function has access to that object.
public function Tasks(StoreInterface $store) { ...
In my experience, that is NOT true of the access() method (at least when using a type-hinted parameter as we are doing here). You get a string, so you'll have to load the store manually.
public function taskAccess(string $store, AccountInterface $account) {
$store = \Drupal\commerce_store\Entity\Store::load($store);
// Check store owner against current user.
if ($store->access('edit', $account)) {
return AccessResult::allowed();
}
else {
return AccessResult::forbidden();
}
Also we need to define $account in the routing file now, as we are using type-hinted parameters (I think). So add that to the options:.
task_notify.store_page.tasks:
path: '/store/{store}/tasks'
defaults:
_controller: '\Drupal\task_notify\Controller\TaskNotifyStoreController::Tasks'
_title: 'Liste des tâches'
requirements:
_custom_access: '\Drupal\task_notify\Controller\TaskNotifyStoreController::taskAccess'
options:
parameters:
store:
type: entity:commerce_store
account: \Drupal\Core\Session\AccountProxy
$account is one of a few special parameters that we can type-hint this way. More info: https://www.drupal.org/docs/8/api/routing-system/access-checking-on-routes/advanced-route-access-checking
I created a EventListener to set the locale based on the user preferences, i set the langage like this in my listener:
$request->setLocale($user->getLanguage());
$request->getSession()->set('_locale',$user->getLanguage());
I tried both..
I register the Listener in the service.yml:
app.event_listener.locale:
class: 'AppBundle\EventListener\LocaleListener'
arguments:
- '#security.token_storage'
tags:
- {name: 'kernel.event_listener', event: 'kernel.request', method: 'onKernelRequest'}
I also tried to add a priority: 17 to the service but it does not change anything...
The listener seems to works, i can get the Locale in my controller with a $request->getLocale()(or session).
But Twig is still in the default language I defined in the config.yml:
parameters:
locale: fr
I'm pretty lost now, any tips ?
I tried a lot of stuff (change the priority, check if the locale is passed to the front etc...)
Finally i forced the translator in my EventListener:
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
if ($this->tokenStorage->getToken()) {
$user = $this->tokenStorage->getToken()->getUser();
if ($user && $user instanceof User) {
$request->setLocale($user->getLanguage());
} elseif ($request->query->has('locale')) {
$request->setLocale($request->query->get('locale'));
} else {
$request->setLocale($request->getPreferredLanguage());
}
}
$this->translator->setLocale($request->getLocale());
}
I don't understand why, this should be done in the Symfony translator, but it works...
You have to set the locale for the translator to get the right translation in templates.
E.g in controller:
$this->get('translator')->setLocale($user->getLanguage());
My application has the following hierarchy of roles:
ROLE_SUPER_ADMIN
ROLE_ADMIN
ROLE_USER
I have my controller that makes this work:
/**
* #Route("/admin/delete/{id}", name="_admin_delete")
* #Secure(roles="ROLE_ADMIN")
*/
public function deleteuserAction($id)
{
$user = $this->container->get('fos_user.user_manager')->findUserBy(array('id' => $id));
if (null === $user) {
throw $this->createNotFoundException('User id not found');
}
if ($user->hasRole('ROLE_ADMIN')) {
// You can not delete this user!
throw new ...
}
// delete user
$this->container->get('fos_user.user_manager')->deleteUser($user);
// ...
}
deleteuserAction is accessible to all those with ROLE_ADMIN.. but I want that they can not delete users who have the role ROLE_ADMIN or HIGHER.
There is a proper way to do this?
With this configuration you can delete ROLE_SUPER_ADMIN... yeah, you can add it to the list to block it, but with a complicated hierarchy can become difficult to manage.
If you don't want to use ACLs, another option would be to create a custom voter that handles your hierarchy. A basic voter is described here: http://symfony.com/doc/current/cookbook/security/voters.html