Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

26
Login or Register to join the conversation
Tom Avatar

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.

1 Reply
Diana E. Avatar
Diana E. Avatar Diana E. | posted 1 year ago

I've completed a ton of online tutorials, but yours are the best. Everything is well explained and easy to understand.

Reply

Thank you Diana E. it means a lot to us!

Reply
Daniel W. Avatar
Daniel W. Avatar Daniel W. | posted 2 years ago

Ahh pls don't end videos with an expected error and show it in the next one. I spend so mutch time finding this out and then the video started with that haha

Reply

Hey Daniel W.

But then there would not be suspense! I'm kidding :p
Thanks for letting us know, we'll consider your feedback for further tutorials

Cheers!

Reply
Eduard Avatar

Haha don't get me wrong I learned alot while debugging tho ^^
It's just sometimes frustrating because usually I press pause then code -> test -> it breaks. At this point I try to rewatch and see where I made a typo or mistake but I can't find one then I start watching and see that this error was made "on purpose".

Maybe it would be nice to announce that this will break like you guys do from time to time.

Reply

Yep! We'll try to foreshadow this kind of situations and avoid those awkward moments. Cheers!

Reply
triemli Avatar
triemli Avatar triemli | posted 2 years ago | edited

there hi! Can I ask you? Which way do you prefer for admin panel? Bundle? AdminKernel? Or maybe even just everything in one app with /admin area? Thanks!

Reply

Hey WebAdequate,

For admin panel we recommend EasyAdminBundle. It is really simple and covers most use cases out of the box. We even have a screencast about it: https://symfonycasts.com/sc... - though it's for the v2 of this bundle. Recently the bundle got a new major release - v3 - where most configuration is done via plain PHP that allows you to have autocompletes in your code, before it was done on Yaml configuration. We don't have a tutorial about v3 yet, but probably will create one soon, but no any estimations yet.

But if you have a really complex crazy admin - probably SonataAdminBundle is what you need - it's much complex than EasyAdminBundle but more flexible. But for most user cases probably EasyAdminBundle would be enough, but it depends on your project. I'd recommend to go through our tutorial quickly and see its key features.

Cheers!

1 Reply
triemli Avatar

Thanks for the answer!
I hope there isn't overhead to customize an own layout like this one https://wrapbootstrap.com/t...
"Клиент любит глазами". You know what I mean? xD

Reply

Hey WebAdequate,

I see what you mean, yes, in most cases you need to follow client's needs.

Well, how much overhead you would need will depend on your features. If you just want to change styles - loading your custom CSS would be pretty simple. In case you require some special layout changes - it would require much more work of course, especially if you have to implement some new custom features that are not available out of the box.

Good luck with your project!

Cheers!

Reply
Dung L. Avatar
Dung L. Avatar Dung L. | posted 2 years ago

Hello,

everything works fine in development on Windows but when I deployed to Linux server I ran into this error, tried hard but I can not overcome, can you please point me? Thank you!


[2019-12-06 00:51:27] request.INFO: Matched route "app_homepage". {"route":"app_homepage","route_parameters":{"_route":"app_homepage","_controller":"App\\Controller\\ArticleController::homepage"},"request_uri":"https://mysite.ca/","method":"GET"} []
[2019-12-06 00:51:27] security.DEBUG: Checking for guard authentication credentials. {"firewall_key":"main","authenticators":2} []
[2019-12-06 00:51:27] security.DEBUG: Calling getCredentials() on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\LoginFormAuthenticator"} []
[2019-12-06 00:51:27] security.DEBUG: Calling getCredentials() on guard authenticator. {"firewall_key":"main","authenticator":"App\\Security\\ApiTokenAuthenticator"} []
[2019-12-06 00:51:27] security.INFO: Populated the TokenStorage with an anonymous Token. [] []
[2019-12-06 00:51:27] security.DEBUG: Access denied, the user is not fully authenticated; redirecting to authentication entry point. {"exception":"[object] (Symfony\\Component\\Security\\Core\\Exception\\AccessDeniedException(code: 403): Access Denied. at /var/www/mysite.ca/vendor/symfony/se..."} []
[2019-12-06 00:51:27] security.DEBUG: Calling Authentication entry point. [] []
Reply
Dung L. Avatar

there are a couple things to consider:

1) I moved sqldump from mariadb to mysql
2) version of mysql is higher that I vaguely remember Ryan said it supports/not support some json datatype store in mysql for user object or password or roles but can not find that part in this course for sure.

here is my doctrine.yalm:

doctrine:
dbal:
# configure these for your database server
driver: 'pdo_mysql'
server_version: '5.7.28'
charset: utf8mb4
default_table_options:
charset: utf8mb4
collate: utf8mb4_unicode_ci

Reply
Dung L. Avatar

I found the section thanks to the great search box option "in this course" https://symfonycasts.com/sc...

so i set

server_version: '5.6'

but still no go :(

I reloaded data with data fixture, still no luck. reinstalled all database engine that does not help either. I need to hunt for this "security.DEBUG: Access denied, the user is not fully authenticated; redirecting to authentication entry point." :)

Reply
Dung L. Avatar

When commented out line

- { path: ^/, roles: IS_AUTHENTICATED_REMEMBERED }

it works, no more error. I still am trying to understand why.

Here is my security.yalm

access_control:
# but, definitely allow /login to be accessible anonymously
- { path: ^/home, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/show, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
#- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/change-password, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/, roles: IS_AUTHENTICATED_REMEMBERED }
# require the user to fully login to change password
#- { path: ^/change-password, roles: IS_AUTHENTICATED_FULLY }
# if you wanted to force EVERY URL to be protected
#- { path: ^/admin, roles: ROLE_ADMIN }
#- { path: ^/profile, roles: ROLE_USER }
#- { path: ^/account, roles: IS_AUTHENTICATED_FULLY }

Reply
Dung L. Avatar

got it working thanks to this https://www.youtube.com/wat...

Reply

Hey Dung L.!

What was the ultimate issue?

Cheers!

Reply
Dung L. Avatar
Dung L. Avatar Dung L. | weaverryan | posted 2 years ago | edited

Hi weaverryan ,

The core issue is that I never knew, I needed to run this command below for deployment to get .htaccess and write config for vhost, this lead me down to trouble shooting the wrong parts. So I posted the answer to the same question here https://stackoverflow.com/q...

sudo composer require symfony/apache-pack

It is working now, accept I now have another issue and I believe it will be a quick fix but not yet sure how to fix it. I posted my question https://symfonycasts.com/sc... I am sure this question will be good for others as well.

Best regards,

Reply

Hey Dung L.!

> The core issue is that I never knew, I needed to run this command below for deployment to get .htaccess and write config for vhost

Ah, of course! Yes, this is only needed if you deploy via Apache. We talk about this on the section of the docs about servers... but there are so many docs, it's easy to miss stuff I realize: https://symfony.com/doc/cur...

> It is working now, accept I now have another issue and I believe it will be a quick fix but not yet sure how to fix it.

It looks like you and Victor got that one sorted out already :).

Cheers!

Reply
Dung L. Avatar

Thank you for the link and it is all working :).

Reply
Default user avatar
Default user avatar MASQUERADE promotion | posted 3 years ago

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?

Reply
Maik T. Avatar

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!

Reply
Default user avatar
Default user avatar MASQUERADE promotion | Maik T. | posted 3 years ago | edited

Hi Maik T. ,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

Reply

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?

Reply

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

Reply

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "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.17.6
        "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
    }
}