PHP PSR-7 ACL Package

Published Oct 19, 2017

One of the hardest things to do in a web application is to lock certain users to certain functions. Anyone who has tried to DIY their own ACL list or permission structure can attest to it becoming a giant nightmare because Sally in accounting needs this report and that report, but not these other 5, but maybe in the future she might. So to save all of you a lot of time and frustration, I bring to you an easy solution. https://github.com/geggleto/geggleto-acl

The Basics

There are a few concepts we need to cover before we dive into some coding. There are some very important terms to get out of the way. This package is based upon Zend Frameworks ACL and provides an easy to use Array config and PSR-7 middleware.

Resources

These are the end points which you want to secure. Resources have many roles.

Roles

These are labels for different access levels. A Role is assigned to a Resource.

Example PHP Array Config

//Define or Pull your ACL's into the following format
$config = [
    "resources" => ["/", "/dashboard", "/report"],
    "roles" => ["guest", "user1", "user2"],
    "assignments" => [
        "allow" => [
            "guest" => ["/"],
            "user1" => ["/", "/dashboard"],
            "user2" => ["/", "/report"]
        ],
        "deny" => [ //This is actually optional
            "guest" => ["/dashboard", "/report"],
            "user1" => ["/report"],
            "user2" => ["/dashboard"]
        ]
    ]
];

Explanation

In this example we are defining 3 "pages" and 3 "roles". We can see here each "user" has their own set of allowable pages. This is one way to mange your users. It should be noted that by default our middleware will deny anything that is not in the allowable section, so the deny is not required. But for maximum readability it can be nice to put them in anyway.

Example Middleware

//In Slim v3
$app->add(new \Geggleto\Acl\AclRepository([
  "guest" // This is the role of the current Request
], 
//This should be in a nice php file by itself for easy inclusion... include '/path/to/acl/definition.php'
[
    "resources" => ["/", "/no", "/yes"],
    "roles" => ["guest", "user1", "user2"],
    "assignments" => [
        "allow" => [
            "guest" => ["/"],
            "user1" => ["/", "/no"],
            "user2" => ["/", "/yes"]
        ]
    ]
]));

Explanation

In this example we are simply using our sample config, and adding it to our framework Slim v3. The AclRepository class is Invokable so no magic is required to get this working in your Slim v3 project. You should define this in the container like the following.

$container[AclRepository::class] = function ($c) {
    return new AclRepository(
    	["guest"],
        [
          "resources" => ["/", "/no", "/yes"],
          "roles" => ["guest", "user1", "user2"],
          "assignments" => [
              "allow" => [
                  "guest" => ["/"],
                  "user1" => ["/", "/no"],
                  "user2" => ["/", "/yes"]
              ]
          ]
        ]
    );
};
//Entire Application
$app->add(AclRepository::class);

//Only certain Routes
$app->group('', function () {
  //route definitions here
})->add(AclRepository::class);

In the code above we are just registering the AclRepoistory class in Slim's Container. This might be more appropriate if you don't need to apply the ACL to the entire application but instead a small subset of routes.

Dynamic Route Patterns

So far we have had some very simple and straight forward routes to protect. However we do need to account for route patterns that have wildcards in it. AclRepository's middleware checks the actual route pattern of an object if a basic route match fails. That is to say that if we were to have a route "/roles/{id}" we can check it.

Let's take a look at another sample config this time with a bit more difficult route.

//Array Config
 [
    "resources" => ["/", "/login", "/grid", "/404", "/logout", "/roles", "/roles/{userId}"],
    "roles" => ["guest", "grid", "roles"],
    "assignments" => [
        "allow" => [
            "guest" => ["/", "/404", "/login"],
            "grid" => [ '/grid', '/logout' ],
            "roles" => ['/roles', '/roles/{userId}']
        ],
        "deny" => []
    ]
];

In this config above you can see we are defining a route /roles/{userId} . This matches a route in our application with the same pattern. We can see that someone with the Roles permission can access it. This works out of the box with AclRepository when you are using Slim v3. The only thing required is that you set 'determineRouteBeforeAppMiddleware' = true. You can see the Slim documentaton on how to do that, it's quite literally a one-line change.

This change in slim set's the actual route object into the Request object where AclRepsitory can call getPattern() to match against.

If you are not using slim 3... read on my friend.

So your not using Slim 3... that's okay I got your back

Let's take a look at the actual middleware code that AclRepository ships with.

$app->add(function (Request $request, Response $res, $next) {
    /** @var $aclRepo AclRepository */ 
    $aclRepo = $this->get(AclRepository::class); //In Slim 3 the container is bound to function definitions
    $allowed = false; // We assume that the user cannot access the route

    $route = '/' . ltrim($request->getUri()->getPath(), '/'); //We construct our path

    try { //Check here... This will pass when a route is simple and there is no route parameters
        $allowed = $aclRepo->isAllowedWithRoles($aclRepo->getRole(), $route);
    } catch (InvalidArgumentException $iae) { //This is executed in cases where there is a route parameters... /user/{id:} 
        $fn = function (ServerRequestInterface $requestInterface, AclRepository $aclRepo) {
            //This will likely only work in Slim 3... This requires the determineRouteBeforeAppMiddleware => true to be set in the container
            $route = $requestInterface->getAttribute('route'); // Grab the route to get the pattern
            if (!empty($route)) {
                foreach ($aclRepo->getRole() as $role) {
                    if ($aclRepo->isAllowed($role, $route->getPattern())) { // check to see fi the user can access the pattern
                        return true; //Is allowed
                    }
                }
            }
            return false;
        };

        $allowed = $fn($request, $aclRepo); // Execute the fail-safe
    }

    if ($allowed) {
        return $next($request, $res);
    } else {
        return $res->withStatus(401); //Is not allowed. if you need to render a template then do that.
    }
});

Yeah okay, I get it. There's a lot of stuff going on and this might not make a lot of sense. Please bear with me. It's all quite simple.

What you are really going to want to do, is redefine $fn. You will want to find a way to get the Pattern of the route that was being executed. Other frameworks probably do this differently, and I happen to use Slim 3 at work, so please do feel free to PR the repo when you find a new way to work this into different frameworks.

$fn's job is to evaluate the request for a non-standard route ie a dynamic route. It can be broken down into a few simple steps.

Get the current route object. Ie the Route object that is bein dispatched
Get the route pattern
Test it using $aclRepo->isAllowed($role, $pattern)
Return true or false...
Profit.
Seriously that is it. know it looks super scary... but it really is pretty straight forward.

o7

Permalink Modified slightly for your viewing pleasure.

Discover and read more posts from Glenn Eggleton
get started