Buy
Buy

Article Admin & Low-Level Access Controls

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Each Article's author is now a proper relationship to the User entity, instead of a string. That's great... except that we haven't updated anything else yet in our code to reflect this. Refresh the homepage. Yep! A big ol' error:

Exception thrown rendering the template Catchable Fatal Error: Object of Class Proxies\__CG__\App\Entity\User cannot be converted to string.

Wow! Two important things here. First, whenever you see this "Proxies" thing, ignore it. This is an internal object that Doctrine sometimes wraps around your entity in order to enable some of its lazy-loading relation awesomeness. The object looks and works exactly like User.

Second, the error itself basically means that something is trying to convert our User object into a string. This makes sense: in our template, we're just rendering {{ article.author }}:

... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
<!-- Article List -->
<div class="col-sm-12 col-md-8">
... lines 10 - 20
{% for article in articles %}
<div class="article-container my-1">
<a href="{{ path('article_show', {slug: article.slug}) }}">
... line 24
<div class="article-title d-inline-block pl-3 align-middle">
... lines 26 - 34
<span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/alien-profile.png') }}"> {{ article.author }} </span>
... line 36
</div>
</a>
</div>
{% endfor %}
</div>
... lines 42 - 61
</div>
</div>
{% endblock %}

That was a string before, but now it's a User object.

We could go change this to article.author.firstName. Or, we can go into our User class and add a public function __toString() method. Return $this->getFirstName():

... lines 1 - 13
class User implements UserInterface
{
... lines 16 - 240
public function __toString()
{
return $this->getFirstName();
}
}

As soon as we do that... we're back!

Adding the Edit Endpoint

What I really want to talk about is controlling access in your system on an object-by-object basis. Like, User A can edit this Article because they are the author, but not that other Article. Open ArticleAdminController and add a new endpoint: public function edit():

... lines 1 - 14
class ArticleAdminController extends AbstractController
{
... lines 17 - 33
public function edit(Article $article)
{
... line 36
}
}

Add the normal route with a URL of /admin/article/{id}/edit. I won't give it a name yet:

... lines 1 - 14
class ArticleAdminController extends AbstractController
{
... lines 17 - 30
/**
* @Route("/admin/article/{id}/edit")
*/
public function edit(Article $article)
{
... line 36
}
}

Next, add an argument to the method: Article $article:

... lines 1 - 4
use App\Entity\Article;
... lines 6 - 14
class ArticleAdminController extends AbstractController
{
... lines 17 - 33
public function edit(Article $article)
{
... line 36
}
}

Because Article is an entity, SensioFrameworkExtraBundle - a bundle we installed a long time ago - will use the {id} route parameter to query for the correct Article.

To see if this is working, dd($article):

... lines 1 - 4
use App\Entity\Article;
... lines 6 - 14
class ArticleAdminController extends AbstractController
{
... lines 17 - 33
public function edit(Article $article)
{
dd($article);
}
}

Oh, and remember: this entire controller class is protected by ROLE_ADMIN_ARTICLE:

... lines 1 - 11
/**
* @IsGranted("ROLE_ADMIN_ARTICLE")
*/
class ArticleAdminController extends AbstractController
{
... lines 17 - 37
}

To get a valid Article ID, find your terminal and run:

php bin/console doctrine:query:sql 'SELECT * FROM article'

Ok - we'll use 20. Fly over to you browser and... hit it: /admin/article/20/edit. That bounces us to the login page. Use an admin user: [email protected] password engage.

Perfect! We're back on the Article edit page, access is granted and Doctrine queried for the Article object.

Planning the Access Controls

And this is where things get interesting. I want to continue to require ROLE_ADMIN_ARTICLE to be able to go to the new article page. But, down here, if you're editing an article, I want to allow access if you have ROLE_ADMIN_ARTICLE or if you are the author of this Article. This is the first time that we've had to make an access decision that is based on an object - the Article.

Manually Denying Access

Start by moving @IsGranted() from above the class to above the new() method:

... lines 1 - 10
class ArticleAdminController extends AbstractController
{
/**
... line 15
* @IsGranted("ROLE_ADMIN_ARTICLE")
*/
public function new(EntityManagerInterface $em)
{
... lines 20 - 26
}
... lines 28 - 35
}

Thanks to this, our edit() endpoint is temporarily open to the world.

Right now, we're looking at article id 20. Go back to your terminal. Ok, this article's author is user 18. Find out who that is:

php bin/console doctrine:query:sql 'SELECT * FROM user WHERE id = 18'

Ok, cool: the author is [email protected]. Go back to the browser, go to the login page, and log in as this user: [email protected], password engage.

Perfect! We still have access but... well... anyone has access to this page right now.

The simplest way to enforce our custom security logic is to add it right in the controller. Check it out: if ($article->getAuthor() !== $this->getUser()) and if !$this->isGranted('ROLE_ADMIN_ARTICLE'), then throw $this->createAccessDeniedException('No access!'):

... lines 1 - 11
class ArticleAdminController extends AbstractController
{
... lines 14 - 31
public function edit(Article $article)
{
if ($article->getAuthor() != $this->getUser() && !$this->isGranted('ROLE_ADMIN_ARTICLE')) {
throw $this->createAccessDeniedException('No access!');
}
dd($article);
}
}

The $this->isGranted() method is new to us, but simple: it returns true or false based on whether or not the user has ROLE_ADMIN_ARTICLE. We also haven't seen this createAccessDeniedException() method yet either. Up until now, we've denied access using $this->denyAccessUnlessGranted(). It turns out, that method is just a shortcut to call $this->isGranted() and then throw $this->createAccessDeniedException() if that returned false. The cool takeaway is that, the way you ultimately deny access in Symfony is by throwing a special exception object that this method creates. Oh, and the message - No access! - that's only shown to developers.

Let's try it! Reload the page. We totally get access because we are the author of this article. Mission accomplished, right? Well... no! This sucks! I don't want this important logic to live in my controller. Why not? What if I need to re-use this somewhere else? Duplicating security logic is a bad idea. And, what if I need to use it in Twig to hide or show an edit link? That would really be ugly.

Nope, there's a better way: a wonderful system called voters.

Leave a comment!

  • 2019-03-12 Cryptoblob

    This doesn't make sense, the users don't have 'ROLE_ADMIN_ARTICLE' so surely everyone should be denied access, or have I missed something?

    Edit: I understand now, if the current user isn't the author and the user isn't an admin it'll be true and the exception will be thrown. But if the current user is the author or the current user is an admin, the if block won't run.

  • 2019-03-04 Diego Aguiar

    Ok, so your route exists and indeed works well. So, the problem relyes on your LoginAuthenticator. What do you do when you process a successful login? In other words. How did you implement the "onAuthenticationSuccess" method?

  • 2019-03-02 MASQUERADE promotion

    Hi Diego Aguiar ,for sure, let's see that.
    1. I am trying to go to, for instance, /admin/article/new
    2. I am not logged in, so I have been redirected to the login page
    3. Entering creds, and... wooh - error "Unable to generate a URL for the named route "http://thecleanstreet.loc/admin/article/new" as such route does not exist."
    4. If I put the link to direct opening - it'll work. and I will be logged in.

    /**
    * @Route("/login", name="app_login")
    * @param AuthenticationUtils $authUtils
    * @return \Symfony\Component\HttpFoundation\Response
    */
    public function login(AuthenticationUtils $authUtils)
    {
    $err = $authUtils->getLastAuthenticationError();
    $lastUsername = $authUtils->getLastUsername();

    return $this->render('security/login.html.twig', [
    'last_username' => $lastUsername,
    'err' => $err,
    ]);
    }


    /**
    * @Route(path="/admin/article/new", name="article_admin_new")
    * @param EntityManagerInterface $em
    * @param Request $request
    * @return Response
    * @IsGranted("ROLE_ADMIN_ARTICLE")
    */
    public function new(EntityManagerInterface $em, Request $request)
    {
    $form = $this->createForm(ArticleFormType::class);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
    /** @var Article $art */
    $art = $form->getData();

    $em->persist($art);
    $em->flush();

    $this->addFlash('success', 'Article has been created.');

    return $this->redirectToRoute('article_admin_list');
    }

    return $this->render('article_admin/new.html.twig', [
    'articleForm' => $form->createView(),
    ]);
    }


    -------------------------- -------- -------- ------ -----------------------------------
    Name Method Scheme Host Path
    -------------------------- -------- -------- ------ -----------------------------------
    app_account ANY ANY ANY /account
    api_account ANY ANY ANY /api/account
    article_admin_new ANY ANY ANY /admin/article/new
    article_admin_edit ANY ANY ANY /admin/article/{id}/edit
    article_admin_list ANY ANY ANY /admin/article
    homepage ANY ANY ANY /
    single_article ANY ANY ANY /news/{slug}
    article_liker_like POST ANY ANY /news/{slug}/likes
    comment_admin ANY ANY ANY /admin/comment
    app_login ANY ANY ANY /login
    app_logout ANY ANY ANY /logout
    app_register ANY ANY ANY /register
    _twig_error_test ANY ANY ANY /_error/{code}.{_format}
    _wdt ANY ANY ANY /_wdt/{token}
    _profiler_home ANY ANY ANY /_profiler/
    _profiler_search ANY ANY ANY /_profiler/search
    _profiler_search_bar ANY ANY ANY /_profiler/search_bar
    _profiler_phpinfo ANY ANY ANY /_profiler/phpinfo
    _profiler_search_results ANY ANY ANY /_profiler/{token}/search/results
    _profiler_open_file ANY ANY ANY /_profiler/open
    _profiler ANY ANY ANY /_profiler/{token}
    _profiler_router ANY ANY ANY /_profiler/{token}/router
    _profiler_exception ANY ANY ANY /_profiler/{token}/exception
    _profiler_exception_css ANY ANY ANY /_profiler/{token}/exception.css

  • 2019-02-28 Diego Aguiar

    Hey MASQUERADE promotion

    Can you show me how are you trying to generate such URL? Also, can you see your route if you run bin/console debug:router?

    Cheers!

  • 2019-02-28 MASQUERADE promotion

    Hi there,
    Actually, I've faced the problem when every url being generated during auth, i.e. after logging in, it is not generated because of "unable to generate route". But the route exist and written as annotations, and the name followed by the tutorial like /account, /admin/comment or the current one of Edit an article. By the way, if I am pasting the address directly to the address bar I am getting the page properly.
    What could that be?

  • 2019-01-09 Victor Bocharsky

    Hey Leif,

    That was an inversed if :) And it makes sense to grant access to authors or to admin as you initially said :)

    Cheers!

  • 2019-01-08 Leif__

    I thought you wanted the user to both be the author of an article and have the ROLE

  • 2019-01-08 Victor Bocharsky

    Hey Leif,

    Actually, it depends on your complete expression :) We use the reverse and denying access, look closer (and notice "!" in front of "$this->isGranted('ROLE_ADMIN_ARTICLE')"):


    if ($article->getAuthor() != $this->getUser() && !$this->isGranted('ROLE_ADMIN_ARTICLE')) {
    throw $this->createAccessDeniedException('No access!');
    }

    i.e. if user is not the author but has the role, he will NOT be denied access, because we won't go into the if statement and won't throw the exception :)

    Cheers!

  • 2019-01-08 Leif__

    I'd use || instead of &&
    If the user is not the author but has the role, he wouldn't be denied access