/authorizer

Simple Authorization via PHP Classes

Primary LanguagePHP

Authorizer

Build Status Packagist Version Code Climate

Simple authorization system inspired by elabs/pundit.

Getting Started

Add Authorizer to your composer.json file and run composer update. See Packagist for specific versions.

"deefour/authorizer": "~0.2.1"

>=PHP5.5.0 is required.

Policies

At the core of Authorizer is the notion of policy classes. A policy must extend Deefour\Authorizer\Policy. Each method should return a boolean. For example

use Deefour\Authorizer\Policy;

class ArticlePolicy extends Policy {

  public function edit() {
    return $this->user->id === $this->record->author_id; // Only the article's author is allowed to edit it
  }

}

When a policy class is instantiated, the $user to authorize is provided along with a $record to authorize against. The $record must implement Deefour\Authorizer\Contracts\Authorizable to be "authorizable".

$user    = User::find(1);
$article = $user->articles()->first();

$policy  = new ArticlePolicy($user, $article);

$policy->edit(); //=> true; the $user can edit the $article

Mass Assignment Protection

A special permittedAttributes method can be created on a policy to conditionally provide a whitelist of attributes for a given request by a user to create or modify a record.

use Deefour\Authorizer\Policy;

class ArticlePolicy extends Policy {

  public function permittedAttributes() {
    $attributes = [ 'title', 'body', ];

    // prevent the author and slug from being modified after the article
    // has been persisted to the database.
    if ( ! $this->record->exists) {
      return array_merge($attributes, [ 'user_id', 'slug', ]);
    }

    return $attributes;
  }

}

Closed System

Many apps only allow authenticated users to perform actions. Instead of verifying on every policy action that the current user is not null, unpersisted in the database, or similarly not a legitimate, authenticated user, create a base policy all others will extend

namespace App\Policies;

use Deefour\Authorizer\Contracts\Authorizee as AuthorizeeContract;
use Deefour\Authorizer\Policy;
use Deefour\Authorizer\Exceptions\NotAuthorizedException;

class Policy extends Policy {

  public function __construct(AuthorizeeContract $user, $record) {
    if (is_null($user) or ! $user->exists) {
      throw new NotAuthorizedException('You must be logged in!');
    }

    parent::__construct($user, $record);
  }

}

Scopes

Policy-based scopes are also supported. A policy scope must extend Deefour\Authorizer\Scope and will be required to implement a resolve() method. The return value will typically be an iterable collection of objects the current user is able to access. For example

use Deefour\Authorizer\Scope;

class ArticleScope extends Scope {

  public function resolve() {
    if ($this->user->isAdmin()) {
      return $this->scope->all();
    } else {
      return $this->scope->where('published', true)->get();
    }
  }

}

When a scope class is instantiated, the $user to authorize is provided along with a $scope to manipulate.

$user        = User::find(1);
$query       = Article::newQuery();

$policyScope = new ArticleScope($user, $query);

$policyScope->resolve(); //=> ALL Articles if the $user is an administrator; otherwise only published ones

Authorizable Objects

Any PHP class can be used as the source object for which authorization will be performed as long as it implements Deefour\Authorizer\Contracts\Authorizable. This will require the following methods be defined on the object

  • policyNamespace()
  • policyClass()
  • scopeClass()

A default implementation for this interface is provided in the Deefour\Authorizer\Authorizable trait. A basic implementation for an authorizable object looks something like this

use Deefour\Authorizer\Contracts\Authorizable as AuthorizableContract;
use Deefour\Authorizer\Authorizable;

class Article implements AuthorizableContract {

  use Authorizable;

}

Policy & Scope Class Resolution

Some of the helper methods Authorizer provides automatically derive policy and scope class names based on the FQCN of the passed object. It does this by using the same namespace as the object, appending 'Policy' or 'Scope' to the object name. For example

use Deefour\Authorizer\Authorizer;

$article   = new Article;
$nsArticle = new Foo\Bar\Article;

Authorizer::policy($user, $article);   //=> ArticlePolicy
Authorizer::policy($user, $nsArticle); //=> Foo\Bar\ArticlePolicy

This behavior can be overridden. Both of the Article classes above, regardless of their namespace, may share a single Policies\ArticlePolicy class. A policyNamespace() method can be implemented on both Article and Foo\Bar\Article.

public function policyNamespace() {
  return 'Policies';
}

This will cause the following lookups to occur:

use Deefour\Authorizer\Authorizer;

$article   = new Article;
$nsArticle = new Foo\Bar\Article;

Authorizer::policy($user, $article);   //=> Policies\ArticlePolicy
Authorizer::policy($user, $nsArticle); //=> Policies\ArticlePolicy

Making Classes Aware of Authorization

The Deefour\Authorizer\ProvidesAuthorization trait can be included in any class to make working with policies and scopes easier. Using this trait requires implementing a currentUser() method on the class.

use Deefour\Authorizer\ProvidesAuthorization;

class ArticleController {

  use ProvidesAuthorization;

  protected function currentUser() {
    return app('user') ?: new User;
  }

}

Within the context of the ArticleController class above, the policy class for an object can be generated with simply

$object = new Article;

$this->policy($object); //=> ArticlePolicy

Scoping can be done with similar simplicity

$query = Article::newQuery();

$this->scope($query); //=> Properly scoped collection of Articles via ArticleScope::resolve()

A failing authorization can trigger a loud response, throwing Deefour\Authorizer\Exceptions\NotAuthorizedException. This can short-circuit method execution with a single line of code.

public function edit($id) {
  $object = Article::find($id);

  $this->authorize($object); //=> NotAuthorizedException will be thrown on failure

  echo "You can edit this article!"
}

If the current user is not allowed to edit the specified Article, the method execution will not make it to the echo.

Assumptions Made by the API

Some assumptions are made by this Authorizer trait to provide you with the simple API described above.

When generating a policy class for an object, the following assumptions are made:

  1. The policy class is resolved by taking the FQCN of the object being authorized and appending "Policy" ( this can be overridden).
  2. The user the authorization is for is based on the return value of the currentUser() method.

When generating a policy scope, the following assumptions are made:

  1. The policy class is resolved by taking the FQCN of the object being authorized and appending "Scope" ( this can be overridden).
  2. The user the authorization is for is based on the return value of the currentUser() method.

When calling the authorize() method, a policy class is instantiated and the following assumptions are made:

  1. The policy method called is based on the name of the caller. If called within an edit() controller action, it will look for an edit() method on the policy class.
  2. If the user is not authorized for the action, Authorizer should fail loudly.

Integration with Laravel

A base App\Http\Controllers\Controller controller in Laravel might look as follows with Authorizer integrated

<?php namespace App\Http\Controllers;

use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Deefour\Authorizer\ProvidesAuthorization;
use App\User;

abstract class Controller extends BaseController {

  use ValidatesRequests;
  use ProvidesAuthorization;

  protected function currentUser() {
    return app('auth')->user() ?: new User;
  }

}

All controllers extending this App\Http\Controllers\Controller are now aware of the functionality Authorizer provides.

Service Provider

Authorizer comes with a service provider for Deefour\Authorizer\Authorizer. In Laravel's config/app.php file, add the AuthorizationServiceProvider to the list of providers.

'providers' => [

  // ...

  'Deefour\Authorizer\Providers\AuthorizationServiceProvider',

],

The IoC container is responsible for instantiating a single, shared instance of the Deefour\Authorizer\Authorizer class. This is done outside the scope of a controller method, meaning the IoC container has no access to or knowledge of the currentUser method that may exist within a base controller. Because the API provided by the Authorizer does not expect a user to be passed, the service provider looks for configuration in an app/config/authorizer.php file on boot. At a minimum, the config must contain a callable 'user' setting.

<?php

return [

  'user' => function() {

    return Auth::user() ?: new User;

  },

];

To keep things DRY, the currentUser method in the base controller could be modified to take advantage of this same Closure.

public function currentUser() {
  return call_user_func(config('authorizer.user'));
}

The Authorizer can be accessed directly from the application container

app('authorizer')->policy(new Article); //=> ArticlePolicy

or via typehinted methods resolved through the container, like controller actions

use Deefour\Authorizer\Authorizer;
// ...

class ArticleController extends Controller {

  public function new(Authorizer $authorizer) {
    $authorizer->policy(new Article); //=> ArticlePolicy
  }

}

Facade

The Authorizer generated via the IoC container can also be accessed via a facade by the same name. In Laravel's config/app.php file, add the Authorizer facade to the list of aliases.

Add the following to app/config/app.php

'aliases' => [

  // ...

  'Authorizer' => 'Deefour\Authorizer\Facades\Authorizer',

],

and use the facade anywhere in your application

Authorizer::policy(new Article); //=> ArticlePolicy

Helper Methods

Global authorizer() and policy() methods are available for use anywhere in the application, but they're particularly useful within views. For example, to conditionally show an 'Edit' link for a specific $article based on the current user's ability to edit that article

@if (policy($article)->can('edit'))
  <a href="{{ URL::route('articles.edit', [ 'id' => $article->id ]) }}">Edit</a>
@endif

The can() method above is simply an alternative syntax to policy($article)->edit().

Gracefully Handling Unauthorized Exceptions

When a call to authorize fails, a Deefour\Authorizer\Exceptions\NotAuthorizedException exception is thrown. This can be caught by Laravel with a simple middleware.

<?php namespace App\Http\Middleware;

use Closure;
use Deefour\Authorizer\Exceptions\NotAuthorizedException;
use Illuminate\Contracts\Routing\Middleware;

class HandleNotAuthorizedExceptionMiddleware implements Middleware {

  /**
   * Run the request filter.
   *
   * @param  \Illuminate\Http\Request  $request
   * @param  \Closure  $next
   * @return mixed
  */
  public function handle($request, Closure $next) {
    try {
      $response = $next($request);
    } catch (NotAuthorizedException $e) {
      return response('Unauthorized.', 401); // fail gracefully
    }

    return $response;
  }

}

Ensuring Policies Are Used

An after filter can be configured to prevent actions missing authorization checks from being wide open by default. The following could be placed in a controller

public function __construct() {
  $this->afterFilter(function() {
    $this->verifyAuthorized();
  }, [ 'except' => 'index' ]);
}

There is a similar method to ensure a scope is used, which is particularly useful for index actions where a collection of objects is rendered and is dependent on the current user's privileges.

public function __construct() {
  $this->afterFilter(function() {
    $this->requirePolicyScoped();
  }, [ 'only' => 'index' ]);
}

Helping Form Requests

Laravel's Illuminate\Foundation\Http\FormRequest class provides support for an authorize() method. Integrating policies into form request objects is easy. An added benefit is the validation rules can be based on authorization too:

<?php namespace App\Http\Requests;

use Deefour\Authorizer\Authorizer;
use Illuminate\Foundation\Http\FormRequest;

class CreateArticleRequest extends FormRequest {

  public function __construct(Authorizer $authorizer) {
    $this->policy = $authorizer->policy(new Article);
  }

  public function rules() {
    $rules = [
      'title' => 'required'
    ];

    if ( ! $this->policy->can('createWithoutApproval')) {
      $rules['approval_from'] => 'required';
    }

    return $rules;
  }

  public function authorize() {
    return $this->policy->can('create');
  }
}

Standalone Instantiation

Policies and scopes can easily be retrieved using static or instance methods on the Deefour\Authorizer\Authorizer class. The user object to be authorized must be provided as the first argument.

Static Instantiation

The following methods are statically exposed:

  • Authorizer::policy()
  • Authorizer::policyOrFail()
  • Authorizer::scope()
  • Authorizer::scopeOrFail()

For example:

use Deefour\Authorizer\Authorizer;

$user    = User::find(1);
$article = $user->articles()->first();

Authorizer::policy($user, $article);         //=> ArticlePolicy
Authorizer::policyOrFail($user, $article);   //=> ArticlePolicy

Authorizer::scope($user, new Article);       //=> ArticleScope
Authorizer::scopeOrFail($user, new Article); //=> ArticleScope

The ...OrFail version of each method fails loudly with a Deefour\Authorizer\Exceptions\NotDefinedException exception if the policy class Aide tries to instantiate doesn't exist.

Instance Instantiation

A limited version of the above API is available when creating an instance of the Policy class.

  • Authorizer::policy()
  • Authorizer::scope()
  • Authorizer::authorize()
use Deefour\Authorizer\Policy;

$user       = User::find(1);
$article    = $user->articles()->first();
$authorizer = new Authorizer($user);

$authorizer->policy($article);            //=> ArticlePolicy
$authorizer->scope($article);             //=> ArticleScope
$authorizer->authorize($article, 'edit'); //=> true | Deefour\Authorizer\Exceptions\NotAuthorizedException

The policy() and scope() methods are pass-through's to the ...OrFail() methods on the PolicyTrait; exceptions will be thrown if a policy or scope cannot be found.

Contribute

Changelog

0.2.0 - February 4, 2014

  • Adding Authorizee contract to be attached to a User model for easy lookup through service containers.
  • Class Reorganization.
  • Fixes for the Laravel service provider.

0.1.0 - November 13, 2014

License

Copyright (c) 2014 Jason Daly (deefour). Released under the MIT License.