PSR-14: Example - Access voting

in php •  14 days ago

So far in our 5 part series we've dug into the details of Events, Dispatchers, and Providers. An awful lot of flexibility can be had from just three simple methods. But how does it work out in practice?

In today's installment I want to start showing examples of real-world (ish) use cases that can benefit from this design. For these examples I will be using Tukio, my stand-alone PSR-14 implementation, but all will work just as well with any PSR-14 implementation, by design.

Voting based access control

A common "extension point" in many systems is access control, especially in a configurable CMS. You want to limit access to various operations, but which users should have access to what operations could vary based on a wide variety of special-case conditions that you want to allow individual site owners to control.

A common algorithm for that is "at least one yes, and no nos". That is, you want to allow a system integrator to plug in a variety of algorithms that can influence whether or not a user can perform an action, and the user should be allowed if at least one of those plugins says "yes, the user can do this" but none of them explicitly step in and say "nope, deny". If an algorithm has no opinion, that's fine; the default if none have an opinion is to deny access.

We implemented exactly that logic in Drupal 8, but using an ugly setup of procedural hooks and magic constants. (I helped design it, and in hindsight it was an improvement over Drupal 7 but still rather clumsy.)

With PSR-14, though, such an "access voter" mechanism is super easy to implement and also allows us to flex the StoppableEventInterface.

To start off, let's define our Events. For now we'll limit ourselves to document CRUD operations. Our Events look like this:

class AccessCheck implements StoppableEventInterface
{
   protected $document;

   protected $allow = null;

   public function __construct(Document $document)
   {
       $this->document = $document;
   }

   public function document() : Document
   {
       return $this->document;
   }

   public function allow() : void
   {
       $this->allow = true;
   }

   public function deny() : void
   {
       $this->allow = false;
   }

   public function allowed() : bool
   {
       return $this->allow === true;
   }

   public function isPropagationStopped() : bool
   {
       return $this->allow === false;
   }
}

class CreateAccessCheck extends AccessCheck {}
class ReadAccessCheck extends AccessCheck {}
class UpdateAccessCheck extends AccessCheck {}
class DeleteAccessCheck extends AccessCheck {}

We have four Events that would get used in practice, but all extend from a common base class since they're all essentially the same thing. (they could have other methods if appropriate, though.) The AccessCheck class carries a document (the document that the user is trying to act on) to expose it to Listeners. It also then has two methods targeted at Listeners: allow() and deny(). Those allow Listeners to say explicitly "yes, I want to permit the user to perform this operation" or "no, I actively reject the user's feeble attempts at this operation!"

Internally that is tracked as a boolean; however, we default that boolean to null, meaning "no one has an opinion yet". We could just as easily use multiple separate flags if we wanted to; that's an implementation detail fully hidden away from both the Event emitter and the Listeners.

AccessCheck also has a method aimed at the emitter, allowed(), which returns true if and only if the allow() method was called at some point (the flag is true). However, if a Listener called deny() at any point then the flag is false, and isPropagationStopped() will then return true, preventing any further Listeners from running. That is, if at least one Listener called deny() then there's no reason for us to continue.

That's easy enough. Now let's look at how we'd wire up some Listeners. I'll use anonymous functions here for simplicity, but in practice you'd probably wire up some subscriber classes in your Container, or whatever.

$provider = new OrderedListenerProvider();
$dispatcher = new Dispatcher($provider);

$user = get_current_user_someow();

$provider->addListener(function (AccessCheck $event) use ($user) {
   if ($user->isAdmin()) {
       $event->allow();
   }
});

$provider->addListener(function (AccessCheck $event) use ($user) {
   if ($user->isGuest()) {
       $event->deny();
   }
});

$provider->addListener(function (UpdateAccessCheck $event) use ($user) {
   $doc = $event->document();
   $isOwnDocument = $doc->owner() == $user->id();
   if ($isOwnDocument && $user->hasPermission('edit own documents')) {
       $event->allow();
   }
});

$provider->addListener(function (UpdateAccessCheck $event) use ($user) {
   $doc = $event->document();
   $isOwnDocument = $doc->owner() == $user->id();
   $isRecentPost = $doc->createdTimestamp() >= strtotime('-5 minutes');
   if ($isOwnDocument && $isRecentPost) {
       $event->allow();
   }
});

$provider->addListener(function (UpdateAccessCheck $event) use ($user) {
   if (date('l') == 'Tuesday') {
       $event->deny();
   }
});

The first part is just some easy setup your Container would normally handle, so let's look at the Listeners in turn.

  • The first gives an administrator user access to perform any document event, period. If the user is not an admin it has no effect.
  • The second actively blocks a guest user from doing anything at all. If the user is not a guest it has no effect.
  • The third applies only to update events, not to anything else. If the user has a particular permission and it's their own document, then they can edit it. Otherwise it has no effect.
  • The fourth also applies only to update events, and allows a user to edit their own posts for the first five minutes after it was first created, without needing any other permission. (Take that, Twitter!)
  • The fifth blocks anyone from doing anything at all on Tuesdays. Because this site plays Fizzbin.

You could register an infinite number of Listeners as desired, which can use any logic they wish to call allow(), deny(), or do nothing. In practice the order is irrelevant; a micro-optimization would be to put the "deny"-triggering Listeners first so that they short circuit more, but that has no effect on the overall logic.

How about the Emitter, which is what's trying to operate on a Document? Its code is trivially simple, assuming it has access to the Dispatcher:

$allow = $dispatcher->dispatch(new UpdateAccessCheck($document))->allowed();
if (!$allow) {
   // Show an error message or something.
}

For the caller, the only API surface is a PSR-14 dispatcher itself. The AccessCheck class itself is the caller's API that it owns and exposes to "whoever". AccessCheck, being a well-designed class, has methods that are rich domain-sensitive primitives that are nicely self-documenting. (If you think my method names are insufficiently self-documenting, that's cool; go make your own. I won't take it personally.)

It also means if the caller ever decided to change its logic, say to allow access only if "more Listeners said yes than said no", nothing would need to change on the caller side or on the Listener side. That logic is completely encapsulated in the AccessCheck class. It would also allow for individual sub-Events (for specific CRUD operations, or otherwise) to have their own special logic, again without any change to the API. The voting algorithm itself is entirely encapsulated into the Event class.

A dedicated Provider?

One benefit of this setup is that the Listeners can be registered in any way the system integrator wishes. It's perfectly fine to throw all Listeners into one big Provider, but it's also perfectly fine to put all of the access control Listeners into one Provider and then use the utility library's DelegatingProvider to pass AccessCheck events directly to it, skipping all of the other clearly-irrelevant Listeners.

In fact, we could even go a step further. There's no reason a Provider needs to have a generic registration mechanism at all. Consider this class:

class AccessCheckers implements ListenerProviderInterface
{
   public function addAccessVoter(callable $voter, bool $canDeny = false) { ... }
  
   public function getListenersForEvent(object $event) : iterable { ... }
}

This class is a fully capable Provider, but its API is entirely focused on access control voting. It doesn't even mention Events or Listeners in its user-facing API, just "voter" callables. The user can also explicitly specify if the voter (aka Listener) is able to deny access so that those can all be ordered first. Beyond that order doesn't matter so there are no other ordering options. The result is an API that feels tailored to the access voter use case, but internally is piggybacking on the machinery of whatever PSR-14 implementation a system integrator feels like using!

Or we can go a step further; assuming we don't need to allow new voters to be added at runtime, we can use a compiled Provider. Using the same API, let's just piggy pack on Tukio's ProviderBuilder:

class AccessCheckerCompiler extends Crell\Tukio\ProviderBuilder
{
   public function addAccessVoter(callable $voter, bool $canDeny = false)
   {
       $priority = $canDeny ? 1 : -1;
       $this->addlistener($voter, $priority);
   }
}

Now we can expose the addAccessVoter()-based API to the system at compile time, when both the Container and any compiled Providers are being constructed (by whatever mechanism you want). Then it can get compiled just as any other Tukio Provider and integrated into a DelegatingProvider (or AggregateProvider if that's your jam).

Of course, in practice you'd probably want to do a better job of supporting non-static voters than this trivially simple version. Perhaps even require that access voters implement some other interface so that they can be discovered automagically and registered as a service, which then gets compiled down. Such more purpose-built registration mechanisms are left as an exercise to the reader.

PSR-14: The series

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!