Article Admin & Low-Level Access Controls

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

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: admin2@thespacebar.com 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 spacebar4@example.com. Go back to the browser, go to the login page, and log in as this user: spacebar4@example.com, 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!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.8.4
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}