I have written an RBAC (Role-based access control) implementation within my Symfony project a few days ago using Symfony Voters.
This is my universal voter for checking if the member has the ability to, for example, create on a specific endpoint:
class AbilityVoter extends Voter
{
public function __construct(MemberRepositoryInterface $memberRepository)
{
$this-memberRepository = $memberRepository;
}
protected function supports(
string $attribute,
$subject
): bool {
if (!in_array($attribute, ['create'])) {
return false;
}
return true;
}
protected function voteOnAttribute(
string $attribute,
$subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
/** #var Member $member */
$member = $user;
return $this->memberRepository->hasAbility(
$member->getId(),
$attribute,
$subject
);
}
}
There is a part I don’t think I've addressed though: how do I hook into that within command handlers or other such places in my code?
On some example endpoints, that means that restricting access to certain endpoints is not enough and there will need to be RBAC-aware logic somewhere in the endpoint that determines whether the request is allowable.
For example, if the member is not allowed to post content for others but the request contains someone else’s member ID, it should be denied, otherwise, it should be allowed.
Can someone suggest the best way to do this and implement proof-of-concept code for the example above? Should I create some custom voters (that extend my base class) and use that? Or do we need to inject a service into the command handler so that it makes that decision?
Can someone help with that? What would be the beast approach in this case? Thanks
Related
I am using Symfony 5.4 and API-Platform 2.6 and would like to filter the returns based on user roles.
It is about data created by different institutions. Each of these institutions is allowed to see all their own data, but not the data of the other institutions.
But there is also a role (I call it administrator in the following) that is allowed to see all data, but in anonymized form. Here some fields are missing like for example the name. For data protection reasons it is necessary that the data is already filtered.
Now I am looking for the best way to implement this.
It would be nice if the routes do not have to provide the institution ID, but they are automatically added internally and respected on the server side.
For the administrator role I don't see a really good solution yet.
I am open for solutions, as well as alternatives.
Also please excuse my bad English.
I see many questions in one question here ^^
Identify the institution of the connected user
You could add a relation user->institution, that's a simple solution and you'll be able to retrieve the user's institution from the connected user.
From now how do you know if a user is part of an institution?
Filter the data per institution
To illustrate let's imagine you have a Product with a getter for each property:
id
name
user
institution
You could create a ApiPlatform extension, there is a good example that is similar to your usecase.
Example
class FilterByInstitutionExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(private Security $security)
{
}
/**
* {#inheritdoc}
*/
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
$this->addWhere($queryBuilder, $resourceClass, $queryNameGenerator);
}
/**
* {#inheritdoc}
*/
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
{
$this->addWhere($queryBuilder, $resourceClass, $queryNameGenerator);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass, QueryNameGeneratorInterface $queryNameGenerator)
{
$toFilterClasses = [Product::class];
if (!in_array($resourceClass, $toFilterClasses)) {
return;
}
// user isn't connected or anonymous
// UserRoles::ADMIN is pseudo code, replace by whatever your admin role is
if (null === $user = $this->security->getUser() || $this->security->isGranted(UserRoles::ADMIN)) {
return;
}
$institution = $user->getInstitution();
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.institution = :institution', $rootAlias));
$queryBuilder->setParameter('institution', $institution);
}
}
Remove user-sensitive data for admin
In Symfony when you want to modify returned data from the server you generally use denormalizer, you should really check the documentation is pretty well explained. Basically, you want to create a denormalizer for each of your apiplatform resources that contains sensitive user data.
Your denormalizer could look like this. Of course, you'll need to tweak it, it's pseudo code right now :)
class AnonymizedUserDataNormalizer implements NormalizerInterface
{
public function __construct(private NormalizerInterface $normalizer, private Security $security)
{
}
public function normalize($entity, string $format = null, array $context = [])
{
$data = $this->normalizer->normalize($entity, $format, $context);
if (!$this->security->isGranted(UserRoles::ADMIN)) {
return $data;
}
if (isset($data['user'])) {
unset($data['user']['firstName'], $data['user']['lastName']);
}
return $data;
}
public function supportsNormalization($data, string $format = null): bool
{
return $data instanceof Product;
}
}
Then you need to register your service because you're using an ApiPlatform service.
App\Serializer\Normalizer\AnonymizedUserDataNormalizer:
arguments:
$normalizer: '#api_platform.serializer.normalizer.item'
All of this is not the only way to go, it's just a proposal to put you on the road. Feel free to ask if something is not clear :)
We're trying to find the best way to implement dependency injection in a Symfony project with a quite specific problematic.
At user level, our application rely on an "Account" doctrine entity which is loaded with the help of the HTTP_HOST global against a domain property (multi-domain application). Going on the domain example.domain.tld will load the matching entity and settings.
At the devops level, we also need to do batch work with CLI scripts on many accounts at the same time.
The question we are facing is how to write services that will be compatible with both needs?
Let's illustrate this with a simplified example. For the user level we have this and everything works great:
Controller/FileController.php
public function new(Request $request, FileManager $fileManager): Response
{
...
$fileManager->addFile($file);
...
}
Service/FileManager.php
public function __construct(AccountFactory $account)
{
$this->account = $account;
}
Service/AccountFactory.php
public function __construct(RequestStack $requestStack, AccountRepository $accountRepository)
{
$this->requestStack = $requestStack;
$this->accountRepository = $accountRepository;
}
public function createAccount()
{
$httpHost = $this->requestStack->getCurrentRequest()->server->get('HTTP_HOST');
$account = $this->accountRepository->findOneBy(['domain' => $httpHost]);
if (!$account) {
throw $this->createNotFoundException(sprintf('No matching account for given host %s', $httpHost));
}
return $account;
}
Now if we wanted to write the following console command, it would fail because the FileManager is only accepting an AccountFactory and not the Account Entity.
$accounts = $accountRepository->findAll();
foreach ($accounts as $account) {
$fileManager = new FileManager($account);
$fileManager->addFile($file);
}
We could tweak in the AccountFactory but this would feel wrong...
In reality this is even worse because the Account dependency is deeper in services.
Does anyone have an idea how to make this properly ?
As a good practice, you should create an interface for the FileManager and set this FileManagerInterface as your dependency injection (instead of FileManager).
Then, you can have different classes that follow the same interface rules but just have a different constructor.
With this approach you can implement something like:
Service/FileManager.php
interface FileManagerInterface
{
// declare the methods that must be implemented
public function FileManagerFunctionA();
public function FileManagerFunctionB(ParamType $paramX):ReturnType;
}
FileManagerInterface.php
class FileManagerBase implements FileManagerInterface
{
// implement the methods defined on the interface
public function FileManagerFunctionA()
{
//... code
}
public function FileManagerFunctionB(ParamType $paramX):ReturnType
{
//... code
}
}
FileManagerForFactory.php
class FileManagerForFactory implements FileManagerInterface
{
// implement the specific constructor for this implementation
public function __construct(AccountFactory $account)
{
// your code here using the account factory object
}
// additional code that is needed for this implementation and that is not on the base class
}
FileManagerAnother.php
class FileManagerForFactory implements FileManagerInterface
{
// implement the specific constructor for this implementation
public function __construct(AccountInterface $account)
{
// your code here using the account object
}
// additional code that is needed for this implementation and that is not on the base class
}
Ans last but not least:
Controller/FileController.php
public function new(Request $request, FileManagerInterface $fileManager): Response
{
// ... code using the file manager interface
}
Another approach that also looks correct is, assuming that FileManager depends on an AccountInstance to work, changes could be made to your FileManager dependency to have the AccountInstance as a dependency instead of the Factory. Just Because in fact, the FileManager does not need the factory, it needs the result that the factory generates, so, automatically it is not FileManager's responsibility to carry the entire Factory.
With this approach you will only have to change your declarations like:
Service/FileManager.php
public function __construct(AccountInterface $account)
{
$this->account = $account;
}
Service/AccountFactory.php
public function createAccount():AccountInterface
{
// ... your code
}
I cant retrieve authenticated webmaster in controller. As you can see below, i authenticate user in constructor via $this->middleware:
class DomainController
.....
public function __construct()
{
$this->middleware('auth:webmasters');
}
public function requestNewName(Request $request, Webmaster $webmaster, DomainRepositoryInterface $domainRepository): array
{
// $webmaster->id === null here
/** #var Webmaster $webmaster */
$webmaster = Auth::user(); // $webmaster->id === 1, all OK
$domainRepository->requestChangeName($webmaster, $request->input('newName', ''));
return ['result' => true];
}
....
I think i need to bind it somewhere, but i dont understand where or how?
P. S.
Now i have in AuthServiceProvider:
foreach ([Webmaster::class, Admin::class] as $class) {
$this->app->bind($class, static function($app) use ($class) {
$authenticated = Auth::user();
/** #noinspection GetClassUsageInspection */
return $authenticated && get_class($authenticated) === $class ? $authenticated : null;
});
}
}
And call this function in boot method. I bet that laravel has something for it.
You want the webmaster to be injected into your method by laravel's dependency injection.
The way you would do this is through the Service Container, which is the guy who handles the injections. When you as for a Webmaster $webmaster, it looks for a binding with that type, since you haven't done an explicit bind, it tries to give you an instance anyways, but that's a generic one.
All you gotta do is add this code in your service provider:
$this->app->bind('App\Webmaster', function ($app) {
return Auth::user();
});
Now laravel knows how you want Webmaster to be injected into the function.
This should be a comment but I don't have enough reputation. Your code looks ok to me. But one thing comes to mind.
Since $webmaster->id gives you null means that the Webmaster class is imported properly in the DomainController. But maybe you haven't imported the Webmaster class properly in the AuthServiceProvider. Also maybe dd(get_class($webmaster)); will help. What does Auth::user() return in your app, anyway?
I have two entites Person and Nursery and a ManyToMany association between them.
A user can have the role ROLE_MANAGER and be a manager for several nurseries.
For that in every action on his dashboard I need to verify if he's linked to the nursery if I don't do it he can modify the nursery slug in the url and have access to a nursery that he is not linked with.
Is there a way to check that on every action in the nursery manager dashboard without copy/paste a verification code in every action ?
As I understood Symfony Events (or Voters ?) can do that but I've never used them before ...
EDIT : Maybe it's easier to understand with a little bit of code !
So my nursery dashboard function is :
public function dashboardAction($nursery_slug)
{
//$currentUser = $this->get('security.token_storage')->getToken()->getUser();
$nurseryRepo = $this->getDoctrine()->getRepository('VSCrmBundle:Nursery');
$nursery = $nurseryRepo->findOneBy(array('slug' => $nursery_slug));
// Sometimes may help
if(!$nursery)
{
throw $this->createNotFoundException("The nursery has not been found or you are not allowed to access it.");
}
return $this->render("VSCrmBundle:Manager:dashboard.html.twig", array(
'nursery' => $nursery
));
}
To protect this dashboard I need to verify if the current user is linked to the nursery, somethink like :
$verification = $nurseryRepo->findOneBy(array('person' => $currentUser));
if(!$verification){throw accessDeniedException();}
But at the moment I'm obliged to do this test on every action in the manager dashboard ....
There are two things you need to implement to make this work smoothly.
First off, you need a NurseryVoter: http://symfony.com/doc/current/security/voters.html
Something like:
class NurseryVoter extends Voter
{
const MANAGE = 'manage';
protected function supports($attribute, $subject)
{
if (!in_array($attribute, array(self::MANAGE))) {
return false;
}
if (!$subject instanceof Nursery) {
return false;
}
return true;
}
protected function voteOnAttribute($attribute, $nursery, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
// the user must be logged in; if not, deny access
return false;
}
// Check the role and do your query to verify user can manage specific nursery
Wire everything up per the link. And at this point your controller code is reduces to:
$this->denyAccessUnlessGranted('manage', $nursery);
Get all that working first. After that, use a Kernel:Controller event to move the deny access code from the controller to a listener. Follow the docs: http://symfony.com/doc/current/event_dispatcher.html
Your controller listener gets called after the controller is assigned but before the controller action is actually called. The trick here is how to determine which action actually needs the check to be done. There are a couple of approaches. Some folks like to flag the actual controller class perhaps by adding a NurseryManagerInterface. The listeners check the controller to see if it has the interface. But I don't really care for that.
I like to add this sort of stuff directly to the route. So I might have:
// routes.yml
manage_nursery:
path: /manage/{nursery}
defaults:
_controller: manage_nursery_action
_permission: CAN_MANAGE_NURSERY
Your listener would then check the permission.
Updated with a few more details on the kernel listener. Basically you inject the authorization checker and pull _permission from the request object.
class KernelListener implements EventSubscriberInterface
{
// #security.authorization_checker service
private $authorizationChecker;
public function __construct($authorizationChecker,$nuseryRepository)
{
$this->authorizationChecker = $authorizationChecker;
$this->nurseryRepository = $nuseryRepository;
}
public static function getSubscribedEvents()
{
return [
KernelEvents::CONTROLLER => [['onController']],
];
}
public function onController(FilterControllerEvent $event)
{
$request = $event->getRequest();
$permission = $request->attributes->get('_permission');
if ($permission !== 'CAN_MANAGE_NURSERY') {
return;
}
$nursery = $this->nurseryRepository->find($request->attributes->get('nursery');
if ($this->authorizationChecker->isGranted('MANAGE',$nursery) {
return;
}
throw new AccessDeniedException('Some message');
}
We have a fairly large symfony2 code base. Generally our Controller actions would look something like
public function landingPageAction(Request $request) {
//do stuff
return $this->render("view_to_render", $template_data);
}
We have two functionalities that are very generic between all of our controllers:
We tend to pass Controller level template parameters to all of the actions in a specific controller - let's call these "Default Parameters"
We set HTTP cache headers at the end of each Action
Understandably we want to abstract this logic away. In doing so we came up with two approaches. We are not certain which approach is better, both in terms of general OO and SOLID principles, but also in terms of performance and how SF2 recommends things be done.
Both approaches rely on having the controller extend an interface that indicates if the controller has "Default Parameters" (later we are considering also adding Cacheable interface)
use Symfony\Component\HttpFoundation\Request;
interface InjectDefaultTemplateVariablesController {
public function getDefaultTemplateVariables(Request $request);
}
Approach 1
This approach is based on events. We define an object that will store our template variables, as well as (in the future) cache indicators
class TemplateVariables {
protected $template_name;
protected $template_data;
public function __construct($template_name, $template_data) {
$this->template_name = $template_name;
$this->template_data = $template_data;
}
/**
* #param mixed $template_data
* #return $this
*/
public function setTemplateData($template_data) {
$this->template_data = $template_data;
return $this;
}
/**
* #return mixed
*/
public function getTemplateData() {
return $this->template_data;
}
/**
* #param mixed $template_name
* #return $this
*/
public function setTemplateName($template_name) {
$this->template_name = $template_name;
return $this;
}
/**
* #return mixed
*/
public function getTemplateName() {
return $this->template_name;
}
}
We also define events that will be triggered on render and which call the views
class InjectDefaultTemplateVariablesControllerEventListener {
/** #var DelegatingEngine */
private $templating;
private $default_template_variables;
public function __construct($templating) {
$this->templating = $templating;
}
public function onKernelController(FilterControllerEvent $event) {
$controller = $event->getController();
if (!is_array($controller)) {
return;
}
if ($controller[0] instanceof InjectDefaultTemplateVariablesController) {
$this->default_template_variables = $controller[0]->getDefaultTemplateVariables($event->getRequest());
}
}
public function onKernelView(GetResponseForControllerResultEvent $event) {
$controller_data = $event->getControllerResult();
if ($controller_data instanceof TemplateVariables) {
$template_data = (array)$controller_data->getTemplateData();
$template_data = array_merge($this->default_template_variables, $template_data);
$event->setResponse($this->templating->renderResponse($controller_data->getTemplateName(), $template_data));
}
}
}
Finally our Action now becomes
public function landingPageAction(Request $request) {
//do stuff
return new TemplateVariables("view_to_render", $template_data);
}
Approach 2
This approach is based on putting the common logic into a BaseController from which every other controller inherits. We are still keeping the approach of having Child controllers also extend an interface in case they want to use "Default Parameters".
The following is the new method in the base controller that determines if Default Parameters need to be merged with the specific template parameters. Later this method will also handle cache headers using ttl parameter.
public function renderWithDefaultsAndCache($view, array $parameters = array(), Response $response = null, $ttl = null)
{
$default_template_variables = array();
if ($this instanceof InjectDefaultTemplateVariablesController ) {
$default_template_variables = $this->getDefaultTemplateVariables();
}
$template_data = array_merge($default_template_variables, $parameters);
return $this->render($view, $template_data, $response);
}
Action now becomes
public function landingPageAction(Request $request) {
//do stuff
return $this->renderWithDefaultsAndCache("view_to_render", $template_data);
}
Discussion
So far the main arguments for the first approach were that it follows SOLID principles and is easier to extend - iin case more common logic were to be added, it can be put directly into Event Listeners without affecting the controllers.
The main arguments for the second approach were that the logic we are trying to abstract away actually does belong to the controller and not an external event. In addition there was a concern that using events in this manner will result in a poor performance.
We would be really grateful to hear from the experts on which approach is better or possibly suggest a third one that we have missed.
Thank you!
First off I am in no way claiming to be a Symfony 2 architecture expert.
I have a game schedule program which outputs a number of different types of schedules (public, team, referee etc). The various schedules are all similar in that they deal with a set of games but vary in details. The schedules need to be displayed in various formats (html,pdf,xls etc). I also wanted to be able to further tweak things for individual tournaments.
I originally used your second approach by creating a ScheduleBaseController and then deriving various individual schedule controllers from it. It did not work well. I tried to abstract common functionality but the schedules were just different enough that common functionality became complicated and difficult to update.
So I went with an event driven approach very similar to yours. And to answer one of your questions, adding some event listeners will not have any noticeable impact on performance.
Instead of focusing on template data I created what I call an Action Model. Action models are responsible for loading the games based on request parameters and (in some cases) updating the games themselves based on posted data.
Action models are created in the Controller event listener, stored in the request object and then passed to the controller's action method as an argument.
// KernelEvents::CONTROLLER listener
$modelFactoryServiceId = $request->attributes->get('_model');
$modelFactory = $this->container->get($modelFactoryServiceId);
$model = $modelFactory->create($request);
$request->attributes->set('model',$model);
// Controller action
public function action($request,$model)
{
// do stuff
// No template processing at all, just return null
return null;
}
// KernelEvents::VIEW listener
$model = $request->attributes->get('model')
$response = $view->renderResponse($model);
So the controller is mostly responsible for form stuff. It can get data from the model if need be but let's the model handle most of the data related stuff. The controller does no template processing stuff at all. It just returns null which in turn kicks off a VIEW event for rendering.
Lot's of objects? You bet. The key is wiring this up in the route definition:
// Referee Schedule Route
cerad_game__project__schedule_referee__show:
path: /project/{_project}/schedule-referee.{_format}
defaults:
_controller: cerad_game__project__schedule_referee__show_controller:action
_model: cerad_game__project__schedule_referee__show_model_factory
_form: cerad_game__project__schedule_referee__show_form_factory
_template: '#CeradGame\Project\Schedule\Referee\Show\ScheduleRefereeShowTwigPage.html.twig'
_format: html
_views:
csv: cerad_game__project__schedule_referee__show_view_csv
xls: cerad_game__project__schedule_referee__show_view_xls
html: cerad_game__project__schedule_referee__show_view_html
requirements:
_format: html|csv|xls|pdf
Each part is broken up into individual services which, for me at least, makes it easier to customize individual sections and to see what is going on. Is it a good approach? I don't really know but it works well for me.