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

此生再无相见时 提交于 2021-01-28 11:16:52

问题


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.


回答1:


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.



来源:https://stackoverflow.com/questions/64052919/security-voter-on-relational-entity-field-when-not-using-custom-subresource-path

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!