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

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.

Related

Mapping a property based on a join query

I'm using Symfony 5.4 with Entities for:
Org
UserConfig
User
Users are associated with Orgs through the UserConfig entity because Users can belong to more than 1 Org, with different configurations for each.
The UserConfig has the following relations:
/**
* #ORM\ManyToOne(targetEntity=Org::class)
* #ORM\JoinColumn(nullable=false)
*/
private $org;
/**
* #ORM\ManyToOne(targetEntity=User::class, inversedBy="configs")
* #ORM\JoinColumn(nullable=false)
*/
private $user;
Then the User entity relates with a OneToMany
/**
* #ORM\OneToMany(targetEntity=UserConfig::class, mappedBy="user", orphanRemoval=true)
*/
private $configs;
Most of my queries are in the context of a single Org, and I often need the related UserConfig when working with a User.
Is it possible to add a config property to the User entity which I can hydrate with a repository query which joins User and UserConfig on a specific Org?
For example, in my UserRepository I may fetch a group of users based on IDs. (This is a contrived example as I just want to show the config dependency)
public function findByIds(Org $org, array $ids): array
{
return $this->createQueryBuilder('user')
->innerJoin('user.configs', 'config', 'WITH', 'config.org = :org')
->setParameter('org', $org)
->andWhere('user.id IN (:ids)')->setParameter('ids', $ids)
->andWhere('config.active = :true')->setParameter('true', true)
->getQuery()
->getResult();
}
I'd like to call this method and access the related UserConfig as $user->getConfig() when iterating over the result of findByIds(). Is that possible?
It's not just for usability as I am serializing the user objects to JSON for API output and I'd like config to be nested in each user.

How can I have two User that share the same table?

I have an entity User with lots of feature built for it.
/**
* #ORM\Entity(repositoryClass="App\Repository\UserRepository")
* #UniqueEntity("email", message="Email already in use")
* #ORM\HasLifecycleCallbacks
* #Table(name="users")
*/
class User implements UserInterface
{
/* variables + getter & setter */
}
This entity is good as is for most of my User.
However, a few of them will have a special ROLE, ROLE_TEACHER.
With this role, I need to store a lot of new variables specially for them.
If I create a new entity Teacher, doctrine creates a new table with every User's data + the Teacher's data.
/**
* #ORM\Entity(repositoryClass="App\Repository\TeacherRepository")
* #Table(name="teachers")
*/
class Teacher extends User
{
/**
* #ORM\Column(type="string", length=64, nullable=true)
*/
protected $test;
public function __construct()
{
parent::__construct();
}
}
What I want, is for Teacher & User to share the users table and have the teachers table only store the extra data. How could I achieve that ?
This is more of system design problem than implementation problem. as #Gary suggested you can make use of Inheritance Mapping which can have Performance issues, I'd rather suggest re think your schema and make use of database normalization techniques to break up your data into more manageable entities.
You can have User entity :
/**
* #ORM\Entity(repositoryClass="App\Repository\UserRepository")
* #UniqueEntity("email", message="Email already in use")
* #ORM\HasLifecycleCallbacks
* #Table(name="users")
*/
class User implements UserInterface
{
/* variables + getter & setter */
/**
* One user has many attibute data. This is the inverse side.
* #OneToMany(targetEntity="UserData", mappedBy="data")
*/
private $data;
}
With other UserData Entity with OneToMany relationship :
/**
* #ORM\Entity(repositoryClass="App\Repository\UserDataRepository")
* #Table(name="user_data")
*/
class UserData
{
/* variables + getter & setter */
#ORM\Id()
private $id;
/**
* Many features have one product. This is the owning side.
* #ManyToOne(targetEntity="User", inversedBy="data")
* #JoinColumn(name="user_id", referencedColumnName="id")
*/
private $user;
/**
* #ORM\Column(type="string")
*/
private $attribute;
/*
* #ORM\Column(name="value", type="object")
*/
private $value;
}
Now you can have list of user attributes without requiring specific structure to each role. It's scalable and arbitrary.
You can also define same Relation with TeacherData, StudentData or UserProfile Entities with foreign keys and branch your application logic according to the roles. Key is to break data into their separate domains and keep common data in one table. Load related data by querying related entity, this increases readability and makes it easy to break complex structure into manageable codebase.

Understanding Symfony 5 Doctrine ManyToOne relation concepts with Users and Roles

I'm trying to understand Doctrine ORM relationship concepts, because I spent lot of time to create, according to me, a very simple use case, but, I didn't manage to do it because I've got a slack of knowledges...
Here is my issue :
I've got 2 entities, UserApp and RoleApp.
I want to have the possibility to manage roles from users threw a database, instead of hardcoding role, in array format, directly into users table. So to do that I've got a classic foreign key role_id into user_app table which references the primary key into role_app table.
I need, for each user, to get his role, and to bring it in the right format expected for Symfony Authentification :
class UserApp implements UserInterface, \Serializable{
.....
/**
* #var UserRole
* #ORM\ManyToOne(targetEntity="App\Entity\UserRole")
* #ORM\JoinColumn(nullable=false)
*
*/
private $role;
public function getUserRole(): array{ // I know it's wrong but I don't know what is the right type to get
return $this->role->getUserRole($this);
}
/**
* Returns the roles or permissions granted to the user for security.
*/
public function getRoles(): array{
//$role = ["ADMIN_USER"];
$role = $this->getUserRole();
dump($role);
return array_unique($role);
class UserRole{
/**
* #ORM\Column(type="json")
*/
private $appRole = [];
public function getUserRole(UserApp $user): ?array
{
$userRoleRepository = new UserRoleRepository();
$userRole[] = $userRoleRepository->find(1);
dump($userRole);
return $userRole;
}
}
But this code doesn't provide the right format for user's role need for Symfony authentification :(
Finaly I managed to do what I want; here is my solution, I hope it can help...
class UserApp implements UserInterface, \Serializable
{
....
/**
* #var UserRole
* #ORM\ManyToOne(targetEntity="App\Entity\UserRole")
* #ORM\JoinColumn(nullable=false)
*
*/
private $role;
public function getUserRole(): UserRole
{
return $this->role->getRoleUser($this);
}
/**
* Returns the roles or permissions granted to the user for security.
* it's for authentification compliance
*/
public function getRoles(): array
{
$arrRole = $this->getUserRole()->getAppRole();
return array_unique($arrRole);
}
......
class UserRole{
.....
/**
* #ORM\Column(type="json")
*/
private $appRole = [];//For authentification compliance
.....
public function getRoleUser(UserApp $user): ?self
{
return $this;
}`enter code here`

The identifier generation strategy for this entity requires the ID field to be populated before EntityManager#persist() is called

I'm tying to create one to many relations
A have class
class Interview {
/**
* #OneToMany(targetEntity="Question", mappedBy="question")
*/
private $questions;
public function __construct() {
$this->questions = new ArrayCollection();
}
public function __toString() {
return $this->id;
}
/**
* #return Collection|Question[]
*/
public function getQuestions() {
return $this->questions;
}
/**
* #ORM\Column(name="id", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
......
}
another
class Question {
/**
* #ManyToOne(targetEntity="Interview", inversedBy="interview")
* #JoinColumn(name="interview_id", referencedColumnName="id")
*/
private $interview;
public function getInterview() {
return $this->interview;
}
public function setInterview(Interview $interview) {
$this->interview = $interview;
return $this;
}
/**
* #ORM\Column(type="integer")
* #ORM\Id
*/
private $interview_id;
......
}
and Controller for all this
if ($form->isSubmitted() && $form->isValid()) {
$interview = new Interview();
$question = new Question();
$em->persist($interview);
$question->setInterview($interview);
$question->setTitle($request->get('title'));
$em->persist($question);
$em->flush();
return $this->redirectToRoute('homepage');
}
i'm receiving an error:
Entity of type AppBundle\Entity\Question is missing an assigned ID for
field 'interview_id'. The identifier generation strategy for this
entity requires the ID field to be populated before
EntityManager#persist() is called. If you want automatically generated
identifiers instead you need to adjust the metadata mapping
accordingly.
Don't understand what the problem and how to fix it.
To enforce loading objects from the database again instead of serving them from the identity map. You can call $em->clear(); after you did $em->persist($interview);, i.e.
$interview = new Interview();
$em->persist($interview);
$em->clear();
It seems like your project config have an error in doctrine mapped part.
If you want automatically generated identifiers instead you need to
adjust the metadata mapping accordingly.
Try to see full doctrine config and do some manipulation with
auto_mapping: false
to true as example or something else...
Also go this , maybe it will be useful.
I am sure, its too late to answer but maybe someone else will get this error :-D
You get this error when your linked entity (here, the Interview entity) is null.
Of course, you have already instantiate a new instance of Interview.But, as this entity contains only one field (id), before this entity is persited, its id is equal to NULL. As there is no other field, so doctrine think that this entity is NULL. You can solve it by calling flush() before linking this entity to another entity

How to implement role / resources ACL in Symfony2

I'm a bit disconcerted by the way access control lists are implemented in Symfony2.
In Zend Framework (versions 1 & 2), a list of resources and a list of roles are defined and each role is assigned a subset of resources it's allowed to access. Resources and roles are therefore the main vocabulary of ACL implementation, which is not the case in Symfony2, where only roles rule.
In a legacy app database, I have tables defining a list of roles, a list of resources and a list of allowed resources for each role (many-to-many relationship). Each user is assigned a role (admin, super admin, editor, and such).
I need to make use of this database in a Symfony2 application.
My resources look like this : ARTICLE_EDIT, ARTICLE_WRITE, COMMENT_EDIT, etc.
My User entity in Symfony implements the Symfony\Component\Security\Core\User\UserInterface interface and therefore has a getRoles) method.
I intend to use this method to define the allowed resources, which means I use roles as resources (I mean that what's called resources in Zend Framework is called roles here).
Do you confirm that I should use this method ?
This means I don't care anymore about the role (admin, editor, ...) of each user, but only about its resources.
I would then use $this->get('security.context')->isGranted('ROLE_ARTICLE_WRITE') in my controllers.
Is this the right way to do it and wouldn't it be a circumvented way to use roles in Symfony?
To answer this question years later, it was pretty easy to solve.
The solution is to mix the notions of roles and resources.
Let's assume a role table, a resource table and and role_resource many to many relation are defined.
Users are stored in a user table.
Here are the corresponding Doctrine entities:
User:
use Symfony\Component\Security\Core\User\UserInterface;
class User implements UserInterface
{
/**
* #Id #Column(type="integer")
* #GeneratedValue
*/
private $id;
/**
* #ManyToOne(targetEntity="Role")
* #JoinColumn(name="role_id", referencedColumnName="id")
**/
private $role;
// ...
}
Role:
class Role
{
/**
* #Id #Column(type="integer")
* #GeneratedValue
*/
private $id;
/** #Column(type="string") */
private $name;
/**
* #ManyToMany(targetEntity="Resource")
* #JoinTable(name="role_resource",
* joinColumns={#JoinColumn(name="role_id", referencedColumnName="id")},
* inverseJoinColumns={#JoinColumn(name="resource_id", referencedColumnName="id")}
* )
**/
private $resources;
// ...
}
Resource:
class Resource
{
/**
* #Id #Column(type="integer")
* #GeneratedValue
*/
private $id;
/** #Column(type="string") */
private $name;
// ...
}
So now the solution is to implement the getRoles of UserInterface this way:
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\Role\Role;
class User implements UserInterface
{
// ...
/**
* #var Role[]
**/
private $roles;
/**
* {#inheritDoc}
*/
public function getRoles()
{
if (isset($this->roles)) {
return $this->roles;
}
$this->roles = array();
$userRole = $this->getRole();
$resources = $userRole->getResources();
foreach ($resources as $resource) {
$this->roles[] = new Role('ROLE_' . $resource);
}
return $this->roles;
}
}
This way, resources attributed to the current user can be checked this way (considering there is a resource whose name is ARTICLE_WRITE):
$this->get('security.context')->isGranted('ROLE_ARTICLE_WRITE')
I think this will answer your question.
http://symfony.com/doc/current/cookbook/security/acl.html
http://symfony.com/doc/current/cookbook/security/acl_advanced.html
$builder = new MaskBuilder();
$builder
->add('view')
->add('edit')
->add('delete')
->add('undelete');
$mask = $builder->get(); // int(29)
$identity = new UserSecurityIdentity('johannes', 'Acme\UserBundle\Entity\User');
$acl->insertObjectAce($identity, $mask);

Resources