Roles And Permissions Using Laravel Policies

Hello there, how are you, how is it going? This series we are going to explore Laravel policies and how we can fit them together with roles and permissions. In today’s article we are going to only focus on Laravel policies.

1.

Background Story

Let’s say there was this online shop where a user has a bank account. There was this route:

        
            
Route::get('/bank-accounts/{bankAccount}', [BankAccountController::class, 'show'])->middleware('auth');

        
    

This route returns information about a user’s bank account.

Here is the controller:

        
            
<?php


namespace App\Http\Controllers;


use App\Models\BankAccount;


class BankAccountController extends Controller
{
   /**
    * @param BankAccount $bankAccount
    * @return array
    */
   public function show(BankAccount $bankAccount): array
   {
       return $bankAccount->getAttributes();
   }
}

        
    

Model:

        
            
<?php


namespace App\Models;


use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;


class BankAccount extends Model
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];
}

        
    

And migration:

        
            
<?php


use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;


return new class extends Migration
{
   /**
    * Run the migrations.
    *
    * @return void
    */
   public function up()
   {
       Schema::create('bank_accounts', function (Blueprint $table) {
           $table->id();
           $table->unsignedBigInteger('user_id')->index();
           $table->string('iban')->index();
           $table->timestamps();
           $table->softDeletes();
       });
   }


   /**
    * Reverse the migrations.
    *
    * @return void
    */
   public function down()
   {
       Schema::dropIfExists('bank_accounts');
   }
};

        
    

Now since we have the registered ->middleware(‘auth’) on the route we want to permit only logged in users to see their bank accounts.

Therefore, I created 2 bank account in the database:

lara1

Lets login with the first user and test the route. This is the result:

Lara2

Perfect, our user is logged in but what if we tried to access another bank account, the one with id 2 which doesn’t belong to the first user?

Lara3

Oops, that is something which should not be allowed! Imagine someone seeing your bank account from amazon, ebay, ali express or some other online shop. We need to fix this and that’s where Laravel policies come to the rescue!

2.

Creating A Standard Policy

You remember that in the begging of this article I said something like this:

– “… a more generic approach to Laravel policies”

And you might be wondering “Which generic approach???”.

Let me explain, when we talk about policies, we talk about resource authorization. In other words we need to authorize that the user can or cannot execute an operation on a given resource. An operation could be e.g. all the standard CRUD operations.

Out of the box Laravel can authorize these operations:

Lara5

With “… a more generic approach to Laravel policies” it is meant to have 1 standard policy which authorizes those operations versus having 1 policy / model. So let’s create this standard policy. We can do this by running this artisan command.

 php artisan make:policy StandardPolicy –model=BankAccount

Most of the time, a policy only needs to authorize that the resource belongs in some way to the authenticated user. In our case above we need to authorize that the user 1 can access only bank accounts belonging to him.

3.

How Resources Relate To The User

There are 2 main cases which we have to study first: directly related resources and polymorphic related resources.

3.1.

Case 1 – Directly Related Resources

If a resource is directly related to the user through an user_id column e.g. our bank_accounts table, we need to see if the bank account . user_id matches the authenticated user id.

3.2.

Case 2 – Polymorphic Related Resources

In cases where a resource belongs to the user through a polymorphic  relationship, we need to search deeper using the model_type and model_id columns until we find a resource which contains a user_id column. From here, enters case 1 and we know what is to be done.

4.

Writing The Code

When implementing the above theory this is the result:

        
            
<?php


namespace App\Policies;


use App\Models\User;
use Exception;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Schema;


class StandardPolicy
{
   use HandlesAuthorization;


   /**
    * @var string
    */
   protected string $userIdColumn = 'user_id';


   /**
    * @var array|string[]
    */
   protected array $morphColumns = [
       'model_type',
       'model_id'
   ];


   /**
    * Determine whether the user can view the model.
    *
    * @param User $user
    * @param Model $model
    * @return Response|bool
    * @throws Exception
    */
   public function view(User $user, Model $model): Response|bool
   {
       return $this->authorize($user, $model);
   }


   /**
    * @param User $user
    * @param Model $model
    * @return Response
    * @throws Exception
    */
   private function authorize(User $user, Model $model): Response
   {
       switch (true) {
           case (Schema::hasColumn($model->getTable(), $this->userIdColumn)):
               $condition = $this->relatesTo($user, $model);
               break;
           case (Schema::hasColumns($model->getTable(), $this->morphColumns)):
               $condition = $this->morphsTo($user, $model);
               break;
           default:
               throw new Exception('User not authorized.');
       }
       return $condition ? $this->allow() : $this->deny();
   }


   /**
    * @param User $user
    * @param Model $model
    * @return bool
    */
   private function relatesTo(User $user, Model $model): bool
   {
       $relatedUserId = $model->{$this->userIdColumn};


       return is_int($relatedUserId) && $user->id === $relatedUserId;
   }


   /**
    * @throws Exception
    */
   private function morphsTo(User $user, Model $model): bool
   {
       if ($model->model_type === User::class) {
           return $user->id === $model->model_id;
       }


       $morphedModel = $model->model;


       if ($morphedModel instanceof Model) {
           return $this->authorize($user, $morphedModel)->allowed();
       }


       throw new Exception('Expected instance of Model but something else was given!');
   }
}

        
    
5.

Replacing The Standard Policy Discovery

We have to replace the standard policy auto discovery with the standard policy. This can be simply achieved by overriding guessPolicyNamesUsing from the IlluminateSupportFacadesGate class in the  AuthServiceProvider.

        
            
<?php


namespace App\Providers;

use Illuminate\Support\Facades\Gate;
use App\Policies\StandardPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
   /**
    * The model to policy mappings for the application.
    *
    * @var array<class-string, class-string>
    */
   protected $policies = [
       // 'App\Models\Model' => 'App\Policies\ModelPolicy',
   ];


   /**
    * Register any authentication / authorization services.
    *
    * @return void
    */
   public function boot()
   {
       $this->registerPolicies();


       // if policy is registered in $policies property, that will take precedence over discovery
       Gate::guessPolicyNamesUsing(function ($modelClass) {
           return StandardPolicy::class;
       });
   }
}

        
    
6.

Registering Policy To Route

Now, there is one last step remaining. We need to register the required authorization on our route using the can middleware. First, I created a PolicyMethod.php config class containing all the policies methods:

        
            
<?php


namespace App\Policies;


class PolicyMethod
{
   const VIEW_ANY = 'viewAny';
   const VIEW = 'view';
   const CREATE = 'create';
   const UPDATE = 'update';
   const DELETE = 'delete';
   const RESTORE = 'restore';
   const FORCE_DELETE = 'forceDelete';
}

        
    

And then registered the ‘view’ ability on the route:

And create the test routes:

        
            
Route::prefix('/posts')->group(function () {
   Route::get('/form', [PostController::class, 'form']);
   Route::post('/create', [PostController::class, 'create']);


   Route::get('/', [PostController::class, 'index']);
   Route::get('/{post}', [PostController::class, 'show']);
});

        
    
7.

Test of Truth

Let’s try to access the bank account which didn’t belong to the first user:

Lara6

Brilliant! If we accessed the correct bank account, we should be able to access it:

Lara7

Great! Let’s also create a polymorphic resource. We will call it payments.

Payments can relate to a bank account or to a new resource called  virtual bank account. Let’s create this entities:

        
            
class VirtualBankAccount extends Model
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];
}


Schema::create('virtual_bank_accounts', function (Blueprint $table) {
   $table->id();
   $table->unsignedBigInteger('user_id')->index();
   $table->string('code');
   $table->timestamp('activated_at')->nullable();
   $table->timestamp('expires_at')->nullable();
   $table->timestamps();
   $table->softDeletes();
})

class Payment extends Model
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];


   public function model(): MorphTo
   {
       return $this->morphTo();
   }
}

Schema::create('payments', function (Blueprint $table) {
   $table->id();
   $table->morphs('model');
   $table->unsignedBigInteger('product_id')->index();
   $table->timestamps();
   $table->softDeletes();
});

        
    

Good, now let’s create a PaymentController and a route:

        
            
<?php


namespace App\Http\Controllers;


use App\Models\Payment;


class PaymentController extends Controller
{
   /**
    * @param Payment $payment
    * @return array
    */
   public function show(Payment $payment): array
   {
       return $payment->getAttributes();
   }
}

Route::get('/payments/{payment}', [PaymentController::class, 'show'])
   ->middleware('auth')
   ->can(PolicyMethod::VIEW, 'payment');

        
    

It’s time to test the polymorphic relation access. First, I created a virtual bank account for each of our users:

Lara8

Let’s register some payments:

Lara9

We know that our first user with id 1 owns the bank account and the virtual bank account with the id 1. Our policy should not allow him to view the payment belonging to the second user. If we test our theory:

Lara10
Lara11

We have confirmation! The standard policy is working as expected. Only resources belonging to the user can be accessed.

8.

Wrap Up

Laravel policies are a secure and powerful way to authorize users to access resources from the database. Feel free to play around and adjust  the code as you like. In our next blog article we are going to see how we can fit our standard policy with a simple roles & permissions system.

Until then stay tuned, eat a healthy breakfast, enjoy your day and as always, happy coding!

Useful Links:

https://laravel.com/docs/10.x/authorization#creating-policies
Marius Cristea
Full stack developer @ Control F5
OUR WORK
Case studies

We have helped 20+ companies in industries like Finance, Transportation, Health, Tourism, Events, Education, Sports.

READY TO DO THIS
Let’s build something together