Symfony Role management and permissions - symfony

I have a problem. I'm making a new application with Symfony (6) and ApiPlatform (2.8).
I need to make an access system with roles and permissions on my users.
A role gives access to several permissions and a user can have additional permissions.
Therefore, I have a "User" entity which has a "role" attribute of type relation on the "Role" table which defines all possible roles.
I also have a "Permissions" table that defines the possible permissions.
A "RolePermissions" table that defines the possible permissions according to the role.
A "UserPermissions" table that will contain the possible permissions complementary to a user.
Now, I want to use "is_granted("ROLE_ADMIN")" from Symfony. But is_granted() uses the role field of type string. As I have a relationship, how can I check the accesses?
I would like to secure my API routes.
`#[ApiResource(
collectionOperations: [
'get' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
])]`
Do I need to create a special system?
https://symfony.com/doc/current/security/voters.html
If so, how do I do it?

I might have done something like this:
/**
* Get the permissions of the current user.
*
* #return array An array of permissions
*/
public function getRoles()
{
// Get the user's role
$role = $this->getRole();
// If the user doesn't have a role defined, use the default role ROLE_USER
if (!$role) {
$role[] = 'ROLE_USER';
}
// Get the permissions of the user's role
$rolePermissions = $role->getPermissions();
// Get the additional permissions of the user
$userPermissions = $this->getUserPermissions();
// Merge the role permissions and additional permissions using array_unique to avoid duplicates
$permissions = array_unique(array_merge($rolePermissions, $userPermissions));
return $permissions;
}
/**
* Check if the current user has the given permission.
*
* #param string $permission The name of the permission to check
* #return bool True if the user has the permission, false otherwise
*/
public function hasPermission(string $permission): bool
{
// Get the user's permissions
$permissions = $this->getRoles();
// Check if the given permission is in the user's permissions array
return in_array($permission, $permissions);
}
/**
* Get the role object associated with the user.
*
* #return Role|null The role object, or null if no role is defined for the user
*/
public function getRole()
{
return $this->role;
}
/**
* Get the additional permissions of the user.
*
* #return array An array of permissions
*/
public function getUserPermissions()
{
// Retrieve additional user permissions from the UserPermissions table
$userPermissions = $this->getDoctrine()->getRepository(UserPermissions::class)->findBy(['user' => $this]);
// Transformation of Permission objects into an array of permission names
$permissions = array_map(function ($permission) {
return $permission->getName();
}, $userPermissions);
return $permissions;
}
And in role entity:
/**
* Get the permissions associated with the role.
*
* #return array An array of permissions
*/
public function getPermissions()
{
// Retrieving role permissions from the RolePermissions table
$rolePermissions = $this->getDoctrine()->getRepository(RolePermissions::class)->findBy(['role' => $this]);
// Transformation of Permission objects into an array of permission names
$permissions = array_map(function ($permission) {
return $permission->getName();
}, $rolePermissions);
return $permissions;
}
I haven't tried it yet. But this is what I have in mind. And according to the documentation, my roles will start with ROLE_ as well as my permissions.
"The only rule is that every role must start with the ROLE_ prefix - otherwise, things won't work as expected. Other than that, a role is just a string and you can invent whatever you need (e.g. ROLE_PRODUCT_ADMIN)."
I'm going to sleep. I'll be back soon.

Related

Security voter on relational entity field when not using custom subresource path

I have started doing some more advanced security things in our application, where companies can create their own user roles with customizable CRUD for every module, which means you can create a custom role "Users read only" where you set "read" to "2" and create, update, delete to 0 for the user module. And the same for the teams module.
0 means that he have no access at all.
1 means can access all data under company,
2 means can access only things related to him (if he is owner
of an another user),
Which should result in the behavior that, when user requests a team over a get request, it returns the team with the users that are in the team, BUT, since the user role is configured with $capabilities["users"]["read"] = 2, then team.users should contain only him, without the other team members, because user cannot see users except himself and users that he created.
So far I have managed to limit collection-get operations with a doctrine extension that implements QueryCollectionExtensionInterface and filters out what results to return to the user:
when I query with a role that has $capabilities["teams"]["read"] = 2 then the collection returns only teams that user is part of, or teams that he created.
when I query for users with role that has $capabilities["teams"]["read"] = 1 then it returns all teams inside the company. Which is correct.
The problem comes when I query a single team. For security on item operations I use Voters, which checks the user capabilities before getting/updating/inserting/... a new entity to the DB, which works fine.
So the problem is, that when the team is returned, the user list from the manytomany user<->team relation, contains all the users that are part of the team. I need to somehow filter out this to match my role capabilities. So in this case if the user has $capabilities["users"]["read"] = 2, then the team.users should contain only the user making the request, because he has access to list the teams he is in, but he has no permission to view other users than himself.
So my question is, how can add a security voter on relational fields for item-operations and collection-operations.
A rough visual representation of what I want to achieve
/**
* #ORM\ManyToMany(targetEntity="User", mappedBy="teams")
* #Groups({"team.read","form.read"})
* #Security({itemOperations={
* "get"={
* "access_control"="is_granted('user.view', object)",
* "access_control_message"="Access denied."
* },
* "put"={
* "access_control"="is_granted('user.update', object)",
* "access_control_message"="Access denied."
* },
* "delete"={
* "access_control"="is_granted('user.delete', object)",
* "access_control_message"="Access denied."
* },
* },
* collectionOperations={
* "get"={
* "access_control"="is_granted('user.list', object)",
* "access_control_message"="Access denied."
* },
* "post"={
* "access_control"="is_granted('user.create', object)",
* "access_control_message"="Access denied."
* },
* }})
*/
private $users;
I don't think Normalizer is a good solution from a performance perspective, considering that the DB query was already made.
If I understand well, in the end the only problem is that when you make a request GET /api/teams/{id}, the property $users contains all users belonging to the team, but given user's permissions, you just want to display a subset.
Indeed Doctrine Extensions are not enough because they only limits the number of entities of the targeted entity, i.e Team in your case.
But it seems that Doctrine Filters cover this use case; they allow to add extra SQL clauses to your queries, even when fetching associated entities. But I never used them myself so I can't be 100% sure. Seems to be a very low level tool.
Otherwise, I deal with a similar use case on my project, but yet I'm not sure it fit all your needs:
Adding an extra $members array property without any #ORM annotation,
Excluding the $users association property from serialization, replacing it by $members,
Decorating the data provider of the Team entity,
Making the decorated data provider fill the new property with a restricted set of users.
// src/Entity/Team.php
/**
* #ApiResource(
* ...
* )
* #ORM\Entity(repositoryClass=TeamRepository::class)
*/
class Team
{
/**
* #ORM\Id
* #ORM\GeneratedValue
* #ORM\Column(type="integer")
*/
private $id;
/**
* #var User[]
* #ORM\ManyToMany(targetEntity=User::class) //This property is persisted but not serialized
*/
private $users;
/**
* #var User[] //This property is not persisted but serialized
* #Groups({read:team, ...})
*/
private $members = [];
// src/DataProvider/TeamDataProvider.php
class TeamDataProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
/** #var ItemDataProvider */
private $itemDataProvider;
/** #var CollectionDataProvider*/
private $collectionDataProvider;
/** #var Security */
private $security;
public function __construct(ItemDataProvider $itemDataProvider,
CollectionDataProvider $collectionDataProvider,
Security $security)
{
$this->itemDataProvider = $itemDataProvider;
$this->collectionDataProvider = $collectionDataProvider;
$this->security = $security;
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === Team::class;
}
public function getCollection(string $resourceClass, string $operationName = null)
{
/** #var Team[] $manyTeams */
$manyTeams = $this->collectionDataProvider->getCollection($resourceClass, $operationName);
foreach ($manyTeams as $team) {
$this->fillMembersDependingUserPermissions($team);
}
return $manyTeams;
}
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** #var Team|null $team */
$team = $this->itemDataProvider->getItem($resourceClass, ['id' => $id], $operationName, $context);
if ($team !== null) {
$this->fillMembersDependingUserPermissions($team);
}
return $team;
}
private function fillMembersDependingUserPermissions(Team $team): void
{
$currentUser = $this->security->getUser();
if ($currentUser->getCapabilities()['users']['read'] === 2) {
$team->setMembers([$currentUser]);
} elseif ($currentUser->getCapabilities()['users']['read'] === 1) {
$members = $team->getUsers()->getValues();
$team->setMembers($members); //Current user is already within the collection
}
}
}
EDIT AFTER REPLY
The constructor of the TeamDataProvider use concrete classes instead of interfaces because it is meant to decorate precisely ORM data providers. I just forgot that those services use aliases. You need to configure a bit:
# config/services.yaml
App\DataProvider\TeamDataProvider:
arguments:
$itemDataProvider: '#api_platform.doctrine.orm.default.item_data_provider'
$collectionDataProvider: '#api_platform.doctrine.orm.default.collection_data_provider'
This way you keep advantages of your extensions.

Custom decision manager authorisation in Symfony 4

I have a specific authorisation system in my application (asked by my managers). It is based on Joomla. Users are attached to usergroups. Every action (i.e page) in my application are resources and for each resources I have set an access level. I have then to compare the resource access level with the usergroups of the current user to grant access or not to this specific resource.
All those informations are stored in database which are in return entities in Symfony :
User <- ManyToMany -> Usergroups
Menu (all resources with path and access level)
I thought about the Voter system. It is kind alike of what I would want, I think. Can I hijack the support function for this ?
protected function supports($user, $resource)
{
//get usergroups of the $user => $usergroups
//get the access level of the resource => $resource_access
// if the attribute isn't one we support, return false
if (!in_array($usergroups, $resource_access)) {
return false;
}
return true;
}
The get the usergroups and the access level of the resource I will have to do some queries in the database. To use this, then I would to use the denyAccessUnlessGranted() function in all my controller (seems redundant by the way) ?
Do you think it would work or there is another system more suited for this case ? I thought of doing the control in a listener to the kernel.request event too.
Hope I am clear enough, I'm new to symfony and still have some issues to understand how everything are related and working.
The voter component should be a good fit for this, as its a passive approach that lets you implement any logic in a way where its fixable through code, without modifying any database specific acl tree not managed by symfony itself.
Voters are called if you use denyAccessUnlessGranted() or isGranted() either through code, annotation or twig.
Lets take a look at how you want to check if the current user has access to view the index page:
class SomeController {
public function index() {
$this->denyAccessUnlessGranted('VIEW', '/index');
// or use some magic method to replace '/index' with wathever you require,
// like injecting $request->getUri(), just make sure your voter can
// parse it quickly.
// ...
}
}
Now build the a very simple voter:
class ViewPageVoter extends Voter
{
/**
* #var EntityManagerInterface
*/
private $em;
public function __construct(EntityManagerInterface $em) {
$this->em = $em;
}
protected function supports($attribute, $subject)
{
return is_string($subject) && substr($subject, 0, 1) === '/';
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$currentUser = $token->getUser();
if(!$currentUser) {
// no user or authentication, deny
return false;
}
// Do the query to see if the user is allowed to view the resource.
// $this->em->getRepository(...) or
// $this->em->getConnection()
//
// $attribute = VIEW
// $subject = '/index'
// $currentUser = authenticated user
// return TRUE if allowed, return FALSE if not.
}
}
As a nice bonus you can easily see additional details on security voters in the /_profiler of that request, also indicating their respective vote on the subject.

How to disable Blameable-behaviour programmatically in Symfony2

I'm trying to run a console command in symfony2 in which some properties of a certain class are being updated. One of the properties has got a corresponding reviewedBy-property which is being set by the blameable-behaviour like so:
/**
* #var bool
* #ORM\Column(name="public_cmt", type="boolean", nullable=true)
*/
private $publicCmt;
/**
* #var User $publicCmtReviewedBy
*
* #Gedmo\Blameable(on="change", field="public_cmt")
* #ORM\ManyToOne(targetEntity="My\Bundle\EntityBundle\Entity\User")
* #ORM\JoinColumn(name="public_cmt_reviewed_by", referencedColumnName="id", nullable=true)
*/
private $publicCmtReviewedBy;
When i run the task there's no user which can be 'blamed' so I get the following exception:
[Doctrine\ORM\ORMInvalidArgumentException]
EntityManager#persist() expects parameter 1 to be an entity object, NULL given.
However I can also not disable blameable because it's not registered as a filter by the time i start the task and programmatically trying to set the user through:
// create the authentication token
$token = new UsernamePasswordToken(
$user,
null,
'main',
$user->getRoles());
// give it to the security context
$this->getService('security.context')->setToken($token);
doesn't work. Anyone got an idea?
If you use the StofDoctrineExtensionsBundle you can simply do :
$this->container->get('stof_doctrine_extensions.listener.blameable')
->setUserValue('task-user');
see : https://github.com/stof/StofDoctrineExtensionsBundle/issues/197
First of all, I'm not sure if 'field' cares if you use the database column or the property, but you might need to change it to field="publicCmt".
What you should do is override the Blameable Listener. I'm going to assume you are using the StofDoctrineExtensionsBundle. First override in your config:
# app/config/config.yml
stof_doctrine_extensions:
class:
blameable: MyBundle\BlameableListener
Now just extend the existing listener. You have a couple options - either you want to allow for NULL values (no blame), or, you want to have a default user. Say for example you want to just skip the persist and allow a null, you would override as such:
namespace MyBundle\EventListener;
use Gedmo\Blameable\BlameableListener;
class MyBlameableListener extends BlameableListener
{
public function getUserValue($meta, $field)
{
try {
$user = parent::getUserValue($meta, $field);
}
catch (\Exception $e) {
$user = null;
return $user;
}
protected function updateField($object, $ea, $meta, $field)
{
if (!$user) {
return;
}
parent::updateField($object, $ea, $meta, $field);
}
}
So it tries to use the parent getUserValue() function first to grab the user, and if not it returns null. We must put in a try/catch because it throws an Exception if there is no current user. Now in our updateField() function, we simply don't do anything if there is no user.
Disclaimer - there may be parts of that updateField() function that you still need...I haven't tested this.
This is just an example. Another idea would be to have a default database user. You could put that in your config file with a particular username. Then instead of returning null if there is no user from the security token, you could instead grab the default user from the database and use that (naturally you'd have to inject the entity manager in the service as well).
Slight modification of the above answer with identical config.yml-entry: we can check if a user is set and if not: since we have access to the object-manager in the updateField-method, get a default-user, set it and then execute the parent-method.
namespace MyBundle\EventListener;
use Gedmo\Blameable\BlameableListener;
class MyBlameableListener extends BlameableListener
{
protected function updateField($object, $ea, $meta, $field)
{
// If we don't have a user, we are in a task and set a default-user
if (null === $this->getUserValue($meta, $field)) {
/* #var $ur UserRepository */
$ur = $ea->getObjectManager()->getRepository('MyBundle:User');
$taskUser = $ur->findOneBy(array('name' => 'task-user'));
$this->setUserValue($taskUser);
}
parent::updateField($object, $ea, $meta, $field);
}
}

Symfony2 ACL access to multiple objects for multiple users

I'm busy with a Symfony2 application that needs some ACL permissions.
I'm a newbie with Symfony2, so not sure if i'm looking at this the right way.
I have multiple clients, each with multiple accounts.
I have a super admin (ROLE_SUPER_ADMIN) that have access to all clients and all accounts.
Then I have an admin role (ROLE_ADMIN), which will only be allowed access to a specific client and all accounts for that clients.
Then there is agents (ROLE_AGENT), which should only have permission to certain accounts for clients.
I saw on the symfony docs that to give a user access to a specific object, I can use the following code:
// creating the ACL
$aclProvider = $this->get('security.acl.provider');
$objectIdentity = ObjectIdentity::fromDomainObject($account);
$acl = $aclProvider->createAcl($objectIdentity);
// retrieving the security identity of the currently logged-in user
$securityContext = $this->get('security.context');
$user = $securityContext->getToken()->getUser();
$securityIdentity = UserSecurityIdentity::fromAccount($user);
// grant owner access
$acl->insertObjectAce($securityIdentity, MaskBuilder::MASK_OWNER);
$aclProvider->updateAcl($acl);
So when creating a new account, I can give the current logged-in user access to the newly created account.
But how do I grant access to all the other users of the client access to the account?
I don't want to loop through all users and run the above code for every user.
So for example when viewing all clients, I need to know which clients the user has access to, or when viewing the accounts, I need to know which accounts the user has access to.
Also when adding a new user to a client, the user automatically need to have access to all the accounts for that client.
As a side note, I only need to know if the user has access to the account/client. If a user has access, then they are automatically allowed to view/edit/delete etc.
For this case I used a custom security service that verifies ManyToMany relations between entities. It`s not the ideal decision but keep in mind.
First we need to make listener that will be triggered at every controller action.
class SecurityListener
{
protected $appSecurity;
function __construct(AppSecurity $appSecurity)
{
$this->appSecurity = $appSecurity;
}
public function onKernelController(FilterControllerEvent $event)
{
$c = $event->getController();
/*
* $controller passed can be either a class or a Closure. This is not usual in Symfony2 but it may happen.
* If it is a class, it comes in array format
*/
if (!is_array($c)) {
return;
}
$hasAccess = $this->appSecurity->hasAccessToContoller($c[0], $c[1], $event->getRequest());
if(!$hasAccess) {
throw new AccessDeniedHttpException('Access denied.');
}
}
}
In service we have access to request, controller instance and called action. So we can make a decision have user access or not.
class AppSecurity
{
protected $em;
protected $security;
/** #var $user User */
protected $user;
public function __construct(EntityManager $em, SecurityContext $security)
{
$this->em = $em;
$this->security = $security;
if($security->getToken() !== null && !$security->getToken() instanceof AnonymousToken) {
$this->user = $security->getToken()->getUser();
}
}
/**
* #param $controller
* #param string $action
*/
public function hasAccessToContoller($controller, $action, Request $request)
{
$attrs = $request->attributes->all();
$client = $attrs['client'];
/* db query to check link between logged user and request client */
}
}
If you are using very nasty annotations like ParamConverter you can easily extract ready to use entites from request.

Dynamically adding roles to a user

We are using Symfony2's roles feature to restrict users' access to certain parts of our app. Users can purchase yearly subscriptions and each of our User entities has many Subscription entities that have a start date and an end.
Now, is there a way to dynamically add a role to a user based on whether they have an 'active' subscription? In rails i would simply let the model handle whether it has the necessary rights but I know that by design symfony2 entities are not supposed to have access to Doctrine.
I know that you can access an entity's associations from within an entity instance but that would go through all the user's subscription objects and that seems unnecessaryly cumbersome to me.
I think you would do better setting up a custom voter and attribute.
/**
* #Route("/whatever/")
* #Template
* #Secure("SUBSCRIPTION_X")
*/
public function viewAction()
{
// etc...
}
The SUBSCRIPTION_X role (aka attribute) would need to be handled by a custom voter class.
class SubscriptionVoter implements VoterInterface
{
private $em;
public function __construct($em)
{
$this->em = $em;
}
public function supportsAttribute($attribute)
{
return 0 === strpos($attribute, 'SUBSCRIPTION_');
}
public function supportsClass($class)
{
return true;
}
public function vote(TokenInterface $token, $object, array $attributes)
{
// run your query and return either...
// * VoterInterface::ACCESS_GRANTED
// * VoterInterface::ACCESS_ABSTAIN
// * VoterInterface::ACCESS_DENIED
}
}
You would need to configure and tag your voter:
services:
subscription_voter:
class: SubscriptionVoter
public: false
arguments: [ #doctrine.orm.entity_manager ]
tags:
- { name: security.voter }
Assuming that you have the right relation "subscriptions" in your User Entity.
You can maybe try something like :
public function getRoles()
{
$todayDate = new DateTime();
$activesSubscriptions = $this->subscriptions->filter(function($entity) use ($todayDate) {
return (($todayDate >= $entity->dateBegin()) && ($todayDate < $entity->dateEnd()));
});
if (!isEmpty($activesSubscriptions)) {
return array('ROLE_OK');
}
return array('ROLE_KO');
}
Changing role can be done with :
$sc = $this->get('security.context')
$user = $sc->getToken()->getUser();
$user->setRole('ROLE_NEW');
// Assuming that "main" is your firewall name :
$token = new \Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken($user, null, 'main', $user->getRoles());
$sc->setToken($token);
But after a page change, the refreshUser function of the provider is called and sometimes, as this is the case with EntityUserProvider, the role is overwrite by a query.
You need a custom provider to avoid this.

Resources