4

I am developing an application that have a Symfony Messenger component installed to handle async messages. The handler of message need to check some permissions for some particulars users, like if one determinate user should receive an email with information if have edition permissions for example.

To achieve that we use Symfony voters, but when we haven't any user logged into the system like in console commands and async messages is very annoying. What is the best solution to that? That are my main ideas:

  • Force a "login" with the security context for message

    • Pro: One way to check permissions without additional services. Voter is the service.
    • Cons: When I have a collection of users check I should do "security context login" action multiple times. I think that is hard.
  • Design a domain service to handle that.

    • Pros: Solves the problem without force a login
    • Cons: Duplicate code or differents ways to do the same things depending on the context (request, console command or async queue)
  • A service that should be called by voter and domain service

    • Cons: I think that add complexity to more simple issue

What is the best way? Any ideas outside of the previous three points?

Thank you so much

terox
  • 156
  • 2
  • 9

1 Answers1

11

I would probably prefer to check user's permissions before dispatching a message, but let's think how we can approach if it's not a suitable case.

In order to check user permissions, you need to authenticate a user. But in case you're consuming a message asynchronously or executing a console command it's not straightforward, as you don't have an actual user. However, you can pass user id with your message or to a console command.

Let me share my idea of a simple solution for Symfony Messenger. In the Symfony Messenger, there is a concept of Stamps, which allows you to add metadata to your message. In our case it would be useful to pass a user id with a message, so we can authenticate a user within the message handling process.

Let's create a custom stamp to hold a user id. It's a simple PHP class, so no need to register it as a service.

<?php

namespace App\Messenger\Stamp;

use Symfony\Component\Messenger\Stamp\StampInterface;

class AuthenticationStamp implements StampInterface
{
    private $userId;

    public function __construct(string $userId)
    {
        $this->userId = $userId;
    }

    public function getUserId(): string
    {
        return $this->userId;
    }
}

Now we can add the stamp to a message.

$message = new SampleMessage($payload);
$this->messageBus->dispatch(
    (new Envelope($message))
        ->with(new AuthenticationStamp($userId))
);

We need to receive and handle the stamp in order to authenticate a user. Symfony Messenger has a concept of Middlewares, so let's create one to handle stamp when we receive a message by a worker. It would check if the message contains the AuthenticationStamp and authenticate a user if the user is not authenticated at the moment.

<?php

namespace App\Messenger\Middleware;

use App\Messenger\Stamp\AuthenticationStamp;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class AuthenticationMiddleware implements MiddlewareInterface
{
    private $tokenStorage;
    private $userRepository;

    public function __construct(TokenStorageInterface $tokenStorage, UserRepositoryInterface $userRepository)
    {
        $this->tokenStorage = $tokenStorage;
        $this->userRepository = $userRepository;
    }

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        /** @var AuthenticationStamp|null $authenticationStamp */
        if ($authenticationStamp = $envelope->last(AuthenticationStamp::class)) {
            $userId = $authenticationStamp->getUserId();

            $token = $this->tokenStorage->getToken();
            if (null === $token || $token instanceof AnonymousToken) {
                $user = $this->userRepository->find($userId);
                if ($user) {
                    $this->tokenStorage->setToken(new UsernamePasswordToken(
                        $user,
                        null,
                        'provider',
                        $user->getRoles())
                    );
                }
            }
        }

        return $stack->next()->handle($envelope, $stack);
    }
}

Let's register it as a service (or autowire) and include into the messenger configuration definition.

framework:
  messenger:
    buses:
      messenger.bus.default:
        middleware:
          - 'App\Messenger\Middleware\AuthenticationMiddleware'

That's pretty much it. Now you should be able to use your regular way to check user's permissions, for example, voters.

As for console command, I would go for an authentication service, which would authenticate a user if the user id is passed to a command.

Mikhail Prosalov
  • 4,155
  • 4
  • 29
  • 41
  • Thanks a lot for that complete and accurate response. The middleware is, so far, another option and probably the best for the major cases. I agree with you about is better check the permissions before send the message to handler. In my concrete case we generate an event with an object and that must be notified by email to a concrete users that have a certain number of permissions. In that case, I can't validate permissions because there aren't any context. I am thinking the possibility of create a TokenInterface and pass it directly to DecisionManager – terox Oct 07 '20 at 15:27
  • 1
    The concept of Voters designed to check permissions for an authenticated user. Not sure it's the perfect fit to check permissions for multiple users before sending an email. I would implement a service to filter out users, eligible for the email notification. Or fetch eligible users from a repository. Not sure which would be the best option, as I don't have enough context on the application. – Mikhail Prosalov Oct 07 '20 at 16:00
  • @MikhailProsalov 's answer is excellent. I wanted to add only that in my use case, I needed to add the middleware to the `command_bus.middleware` key (in addition to the `router_context` middleware that I was using. This was because in messenger, the consume command is running in `bin/console messenger:consume` – craigh Jan 31 '22 at 20:06
  • also - with Symfony 5.4 the UserPasswordToken args are changed (deprecated and changed in Symfony 6) so that you only need 3 args: `$user`, `$firewallName`, and `$roles`. So I have `($user, 'main', $user->getRoles())` – craigh Feb 03 '22 at 13:35