Roles And Permissions Using Laravel Policies (2)

In our previous blog article we explored how laravel policies work using a more generic approach: one versus many policies. In today’s blog article we are going to implement a simple permission system and use it in our policy authorization flow.

1.

Background story

In a classic example, let’s say an admin panel, we want to allow our admins to access resources based on their assigned role and the role permissions. For this, in our StandardPolicy we only need to verify that the role contains the permission required to access / manipulate the resource. Let’s create the migrations.

2.

Database structure

For this we need to create 3 migrations:

        
            Schema::create('admin_users', function (Blueprint $table) {
   $table->id();
   $table->unsignedBigInteger('admin_role_id');
   $table->string('name');
   $table->string('email')->unique();
   $table->string('password');
   $table->rememberToken();
   $table->timestamps();
   $table->softDeletes();
   $table->timestamp('email_verified_at')->nullable();
});

Schema::create('admin_roles', function (Blueprint $table) {
   $table->id();
   $table->string('name');
   $table->timestamps();
   $table->softDeletes();
});

Schema::create('admin_role_permissions', function (Blueprint $table) {
   $table->id();
   $table->unsignedBigInteger('admin_role_id')->index();
   $table->string('permission')->index();
   $table->timestamps();
   $table->softDeletes();
});

        
    

The relationship is pretty simple: One admin user belongs to one admin role. One admin role has many permissions.

Let’s write the models:

        
            <?php
namespace App\Models;


use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;


/**
* @property AdminRole $adminRole
*/
class AdminUser extends Authenticatable
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];


   /**
    * @return BelongsTo
    */
   public function adminRole(): BelongsTo
   {
       return $this->belongsTo(AdminRole::class);
   }


   /**
    * @param string $permission
    * @return bool
    */
   public function hasPermission(string $permission): bool
   {
       return $this->adminRole->adminRolePermissions()->where('permission', $permission)->exists();
   }
}




<?php


namespace App\Models;


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


class AdminRole extends Model
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];


   /**
    * @return HasMany
    */
   public function adminRolePermissions(): HasMany
   {
       return $this->hasMany(AdminRolePermission::class);
   }


   /**
    * @return hasMany
    */
   public function adminUsers(): HasMany
   {
       return $this->hasMany(AdminUser::class);
   }
}


<?php


namespace App\Models;


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


class AdminRolePermission extends Model
{
   use HasFactory, SoftDeletes;


   protected $guarded = ['id'];
}

        
    
3.

Model permissions

Now we need to create a config file where we can register our model permissions. A model permission will consist of the ability – all the standard crud operations viewAny, view, create, update, delete, restore and forceDelete model – and the model class name in kebab format.

Something like this:

‘view-user’,’view-bank-account’

Then, we will need to associate the permissions with each corresponding model like this: 

model-permissions.php file:

        
            <?php
return [
   \App\Models\User::class => [
       'label' => 'User',
       'values' => [
           'view-user' => 'View User',
           'update-user' => 'Update User',
       ]
   ]
];

        
    
4.

Seeding the database

After we configured our model permissions it’s time to seed the database using the configuration. The seeder needs to:

  1. Create an admin role
  2. Create admin role permissions based on the model-permissions.php config file
  3. Create the admin user using with the role id

I’ve created a AdminUserSeeder:

        
            <?php


namespace Database\Seeders;


use App\Models\AdminRole;
use App\Models\AdminUser;
use Carbon\Carbon;
use Illuminate\Database\Seeder;


class AdminUserSeeder extends Seeder
{
   /**
    * Seed the application's database.
    *
    * @return void
    */
   public function run()
   {
       /**
        * @var AdminRole $adminRole
        */
       $adminRole = AdminRole::query()->firstOrCreate(['name' => 'Super Admin']);


       collect(config('model-permissions'))
           ->pluck('values')
           ->collapse()
           ->keys()
           ->each(function (string $permission) use ($adminRole) {
               $adminRole->adminRolePermissions()->firstOrCreate([
                   'permission' => $permission
               ]);
           });


       AdminUser::query()->firstOrCreate([
           'email' => 'superadmin@test.com'
       ], [
           'admin_role_id' => $adminRole->id,
           'name' => 'John Doe',
           'password' => bcrypt('password'),
           'email_verified_at' => Carbon::now(),
       ]);
   }
}

        
    

You can seed the database by running php artisan db:seed –class=AdminUserSeeder

This is the result:

La1
La2

Perfect, everything looks as expected.

5.

Implementing authorization

Now, our standard policy has to check 3 additional things in order to authorize admin users to view or manipulate resources:

  1. That the authenticated user is an instance of AdminUser
  2. That the requested permission is registered in our configuration
  3. That the user has the permission to perform the operation (permission is attached to his role)
        
            <?php


namespace App\Policies;


use App\Models\AdminUser;
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;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionException;


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|AdminUser $user
    * @param Model $model
    * @return Response|bool
    * @throws Exception
    */
   public function view(User|AdminUser $user, Model $model): Response|bool
   {
       return $this->authorize($user, $model, __FUNCTION__);
   }


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


   /**
    * @param User|AdminUser $user
    * @param Model $model
    * @param string|null $ability
    * @return Response
    * @throws Exception
    */
   private function authorize(User|AdminUser $user, Model $model, string $ability = null): Response
   {
       switch (true) {
           case $this->isAdmin($user):
               $condition = $this->adminHasPermission($user, $model, $ability);
               break;
           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|AdminUser $user
    * @return bool
    */
   protected function isAdmin(AdminUser|User $user): bool
   {
       return $user instanceof AdminUser;
   }


   /**
    * @param AdminUser $user
    * @param Model|string $model
    * @param string $ability
    * @return bool
    * @throws ReflectionException
    */
   protected function adminHasPermission(AdminUser $user, Model|string $model, string $ability): bool
   {
       $class = is_string($model) ? $model : get_class($model);
       $permission = $this->computePermission($ability, $class);


       return $this->permissionIsRegisteredInConfig($class, $permission) && $user->hasPermission($permission);
   }


   /**
    * @param string $ability
    * @param string $class
    * @return string
    * @throws ReflectionException
    */
   private function computePermission(string $ability, string $class): string
   {
       $reflectionClass = new ReflectionClass($class);
       $kebabAbility = Str::kebab($ability);
       $kebabClassName = Str::kebab($reflectionClass->getShortName());
       return "{$kebabAbility}-{$kebabClassName}";
   }


   /**
    * @param string $class
    * @param string $permission
    * @return bool
    */
   private function permissionIsRegisteredInConfig(string $class, string $permission): bool
   {
       $value = config("model-permissions.{$class}.values.{$permission}");
       return !empty($value);
   }


   /**
    * @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!');
   }
}

        
    
6.

Testing

In order to test the above implementation I installed laravel nova, a simple and powerful administration panel which copes seamlessly with policies. I am going to skip the installation in this tutorial. Just follow their documentation and all should be ok. Don’t worry about money, for testing purposes, it’s free! After the installation we need to configure authentication for our new admin_users table. This is how i configured it:

auth.php:

        
            <?php


return [


   /*
   |--------------------------------------------------------------------------
   | Authentication Defaults
   |--------------------------------------------------------------------------
   |
   | This option controls the default authentication "guard" and password
   | reset options for your application. You may change these defaults
   | as required, but they're a perfect start for most applications.
   |
   */


   'defaults' => [
       'guard' => 'web',
       'passwords' => 'users',
   ],


   /*
   |--------------------------------------------------------------------------
   | Authentication Guards
   |--------------------------------------------------------------------------
   |
   | Next, you may define every authentication guard for your application.
   | Of course, a great default configuration has been defined for you
   | here which uses session storage and the Eloquent user provider.
   |
   | All authentication drivers have a user provider. This defines how the
   | users are actually retrieved out of your database or other storage
   | mechanisms used by this application to persist your user's data.
   |
   | Supported: "session"
   |
   */


   'guards' => [
       'web' => [
           'driver' => 'session',
           'provider' => 'users',
       ],
       'admins' => [
           'driver' => 'session',
           'provider' => 'admins',
       ],
   ],


   /*
   |--------------------------------------------------------------------------
   | User Providers
   |--------------------------------------------------------------------------
   |
   | All authentication drivers have a user provider. This defines how the
   | users are actually retrieved out of your database or other storage
   | mechanisms used by this application to persist your user's data.
   |
   | If you have multiple user tables or models you may configure multiple
   | sources which represent each model / table. These sources may then
   | be assigned to any extra authentication guards you have defined.
   |
   | Supported: "database", "eloquent"
   |
   */


   'providers' => [
       'users' => [
           'driver' => 'eloquent',
           'model' => App\Models\User::class,
       ],
       'admins' => [
           'driver' => 'eloquent',
           'model' => App\Models\AdminUser::class,
       ],
       // 'users' => [
       //     'driver' => 'database',
       //     'table' => 'users',
       // ],
   ],


   /*
   |--------------------------------------------------------------------------
   | Resetting Passwords
   |--------------------------------------------------------------------------
   |
   | You may specify multiple password reset configurations if you have more
   | than one user table or model in the application and you want to have
   | separate password reset settings based on the specific user types.
   |
   | The expire time is the number of minutes that each reset token will be
   | considered valid. This security feature keeps tokens short-lived so
   | they have less time to be guessed. You may change this as needed.
   |
   */


   'passwords' => [
       'users' => [
           'provider' => 'users',
           'table' => 'password_resets',
           'expire' => 60,
           'throttle' => 60,
       ],
       'admins' => [
           'provider' => 'admins',
           'table' => 'password_resets',
           'expire' => 5,
           'throttle' => 60,
       ],
   ],


   /*
   |--------------------------------------------------------------------------
   | Password Confirmation Timeout
   |--------------------------------------------------------------------------
   |
   | Here you may define the amount of seconds before a password confirmation
   | times out and the user is prompted to re-enter their password via the
   | confirmation screen. By default, the timeout lasts for three hours.
   |
   */


   'password_timeout' => 10800,


];

        
    

And nova.php:

        
            <?php


use Laravel\Nova\Actions\ActionResource;
use Laravel\Nova\Http\Middleware\Authenticate;
use Laravel\Nova\Http\Middleware\Authorize;
use Laravel\Nova\Http\Middleware\BootTools;
use Laravel\Nova\Http\Middleware\DispatchServingNovaEvent;
use Laravel\Nova\Http\Middleware\HandleInertiaRequests;


return [


   /*
   |--------------------------------------------------------------------------
   | Nova License Key
   |--------------------------------------------------------------------------
   |
   | The following configuration option contains your Nova license key. On
   | non-local domains, Nova will verify that the Nova installation has
   | a valid license associated with the application's active domain.
   |
   */


   'license_key' => env('NOVA_LICENSE_KEY'),


   /*
   |--------------------------------------------------------------------------
   | Nova App Name
   |--------------------------------------------------------------------------
   |
   | This value is the name of your application. This value is used when the
   | framework needs to display the name of the application within the UI
   | or in other locations. Of course, you're free to change the value.
   |
   */


   'name' => env('NOVA_APP_NAME', env('APP_NAME')),


   /*
   |--------------------------------------------------------------------------
   | Nova Domain Name
   |--------------------------------------------------------------------------
   |
   | This value is the "domain name" associated with your application. This
   | can be used to prevent Nova's internal routes from being registered
   | on subdomains which do not need access to your admin application.
   |
   */


   'domain' => env('NOVA_DOMAIN_NAME', null),


   /*
   |--------------------------------------------------------------------------
   | Nova Path
   |--------------------------------------------------------------------------
   |
   | This is the URI path where Nova will be accessible from. Feel free to
   | change this path to anything you like. Note that this URI will not
   | affect Nova's internal API routes which aren't exposed to users.
   |
   */


   'path' => '/nova',


   /*
   |--------------------------------------------------------------------------
   | Nova Authentication Guard
   |--------------------------------------------------------------------------
   |
   | This configuration option defines the authentication guard that will
   | be used to protect your Nova routes. This option should match one
   | of the authentication guards defined in the "auth" config file.
   |
   */


   'guard' => 'admins',


   /*
   |--------------------------------------------------------------------------
   | Nova Password Reset Broker
   |--------------------------------------------------------------------------
   |
   | This configuration option defines the password broker that will be
   | used when passwords are reset. This option should mirror one of
   | the password reset options defined in the "auth" config file.
   |
   */


   'passwords' => 'admins',


   /*
   |--------------------------------------------------------------------------
   | Nova Route Middleware
   |--------------------------------------------------------------------------
   |
   | These middleware will be assigned to every Nova route, giving you the
   | chance to add your own middleware to this stack or override any of
   | the existing middleware. Or, you can just stick with this stack.
   |
   */


   'middleware' => [
       'web',
       HandleInertiaRequests::class,
       DispatchServingNovaEvent::class,
       BootTools::class,
   ],


   'api_middleware' => [
       'nova',
       Authenticate::class,
       Authorize::class,
   ],


   /*
   |--------------------------------------------------------------------------
   | Nova Pagination Type
   |--------------------------------------------------------------------------
   |
   | This option defines the visual style used in Nova's resource pagination
   | views. You may select between "simple", "load-more", and "links" for
   | your applications. Feel free to adjust this option to your choice.
   |
   */


   'pagination' => 'simple',


   /*
   |--------------------------------------------------------------------------
   | Nova Storage Disk
   |--------------------------------------------------------------------------
   |
   | This configuration option allows you to define the default disk that
   | will be used to store files using the Image, File, and other file
   | related field types. You're welcome to use any configured disk.
   |
    */


   'storage_disk' => env('NOVA_STORAGE_DISK', 'public'),


   /*
   |--------------------------------------------------------------------------
   | Nova Currency
   |--------------------------------------------------------------------------
   |
   | This configuration option allows you to define the default currency
   | used by the Currency field within Nova. You may change this to a
   | valid ISO 4217 currency code to suit your application's needs.
   |
   */


   'currency' => 'USD',


   /*
   |--------------------------------------------------------------------------
   | Branding
   |--------------------------------------------------------------------------
   |
   | These configuration values allow you to customize the branding of the
   | Nova interface, including the primary color and the logo that will
   | be displayed within the Nova interface. This logo value must be
   | the absolute path to an SVG logo within the local filesystem.
   |
   */


   // 'brand' => [
   //     'logo' => resource_path('/img/example-logo.svg'),


   //     'colors' => [
   //         "400" => "24, 182, 155, 0.5",
   //         "500" => "24, 182, 155",
   //         "600" => "24, 182, 155, 0.75",
   //     ]
   // ],


   /*
   |--------------------------------------------------------------------------
   | Nova Action Resource Class
   |--------------------------------------------------------------------------
   |
   | This configuration option allows you to specify a custom resource class
   | to use for action log entries instead of the default that ships with
   | Nova, thus allowing for the addition of additional UI form fields.
   |
   */


   'actions' => [
       'resource' => ActionResource::class,
   ],


   /*
   |--------------------------------------------------------------------------
   | Nova Impersonation Redirection URLs
   |--------------------------------------------------------------------------
   |
   | This configuration option allows you to specify a URL where Nova should
   | redirect an administrator after impersonating another user and a URL
   | to redirect the administrator after stopping impersonating a user.
   |
   */


   'impersonation' => [
       'started' => '/',
       'stopped' => '/',
   ],


];

        
    

This is it. Let’s log in into our admin panel.

La3
La4

We are logged in, awesome!

La5

If you pay attention you can see the eye and edit icons. I can view the details or edit a user without problems.

la6

Let’s change the name of the user to Some Dude:

La7

I clicked Update & Continue editing. If I now soft delete the update-user permission:

La8

And change the name of the user, it should fail.

La9
La10

That’s exactly what is supposed to happen. Your mind is mind blown right? Think about all the possibilities and customizations you can achieve with this. But then, right off the bat you are going to say “Wait, what if I wanted to authorize some other permissions than the CRUD ones?” Don’t worry, I got you covered. If there ever was a need like this, just:

  1. Register your permission in kebab format as usual in the config file
  2. Create a policy
  3. Extend the StandardPolicy
  4. Add the method (the permission in camelCase format) to you policy
  5. Check if user has permission
  6. Override the default policy discovery by registering the new policy in AuthServiceProvider
  7. Assign the nova action ExportAsCsv.php to our User.php nova resource actions

Let us walk this extra mile together. For this example I am going to use the nova export as csv feature. I registered a custom permission like this:

        
            <?php
return [
   \App\Models\User::class => [
       'label' => 'User',
       'values' => [
           'view-user' => 'View User',
           'update-user' => 'Update User',
           'export-users-as-csv' => 'Export users as csv'
       ]
   ]
];

        
    

Fulfilled the steps 2-5 literally word by word:

php artisan make:policy UserPolicy

        
            <?php
namespace App\Policies;


use App\Models\AdminUser;
use Illuminate\Support\Str;


class UserPolicy extends StandardPolicy
{
   /**
    * @param AdminUser $user
    * @return bool
    */
   public function exportUsersAsCsv(AdminUser $user): bool
   {
       return $user->hasPermission(Str::kebab(__FUNCTION__));
   }
}

        
    

Registered the new policy:

        
            <?php


namespace App\Providers;


use App\Models\User;
use App\Policies\UserPolicy;
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',
       User::class => UserPolicy::class,
   ];


   /**
    * 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;
       });
   }
}

        
    

And registered the ExportAsCsv.php nova action:

        
            <?php


namespace App\Nova;


use App\Models\AdminUser;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules;
use Laravel\Nova\Actions\ExportAsCsv;
use Laravel\Nova\Fields\Gravatar;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Password;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;


class User extends Resource
{
   /**
    * The model the resource corresponds to.
    *
    * @var class-string<\App\Models\User>
    */
   public static $model = \App\Models\User::class;


   /**
    * The single value that should be used to represent the resource when being displayed.
    *
    * @var string
    */
   public static $title = 'name';


   /**
    * The columns that should be searched.
    *
    * @var array
    */
   public static $search = [
       'id', 'name', 'email',
   ];


   /**
    * Get the fields displayed by the resource.
    *
    * @param  \Laravel\Nova\Http\Requests\NovaRequest  $request
    * @return array
    */
   public function fields(NovaRequest $request)
   {
       return [
           ID::make()->sortable(),


           Gravatar::make()->maxWidth(50),


           Text::make('Name')
               ->sortable()
               ->rules('required', 'max:255'),


           Text::make('Email')
               ->sortable()
               ->rules('required', 'email', 'max:254')
               ->creationRules('unique:users,email')
               ->updateRules('unique:users,email,{{resourceId}}'),


           Password::make('Password')
               ->onlyOnForms()
               ->creationRules('required', Rules\Password::defaults())
               ->updateRules('nullable', Rules\Password::defaults()),
       ];
   }


   /**
    * Get the cards available for the request.
    *
    * @param  \Laravel\Nova\Http\Requests\NovaRequest  $request
    * @return array
    */
   public function cards(NovaRequest $request)
   {
       return [];
   }


   /**
    * Get the filters available for the resource.
    *
    * @param  \Laravel\Nova\Http\Requests\NovaRequest  $request
    * @return array
    */
   public function filters(NovaRequest $request)
   {
       return [];
   }


   /**
    * Get the lenses available for the resource.
    *
    * @param  \Laravel\Nova\Http\Requests\NovaRequest  $request
    * @return array
    */
   public function lenses(NovaRequest $request)
   {
       return [];
   }


   /**
    * Get the actions available for the resource.
    *
    * @param  \Laravel\Nova\Http\Requests\NovaRequest  $request
    * @return array
    */
   public function actions(NovaRequest $request)
   {
       return [
           ExportAsCsv::make()->canSee(function (NovaRequest $request) {
               /**
                * @var AdminUser $adminUser
                */
               $adminUser = $request->user();
               return $adminUser->can('export-users-as-csv', $this->resource);
           })
       ];
   }
}

        
    

If I refresh the page:

La11

Well, nothing changed right? Remember, we did not run the seeder, therefore we cannot see the new action. After running the seeder the table changes like this:

La12

We can see the checkbox on the left, select the users we want to export, click on Actions select dropdown, and export the users. Click Run Action and voila:

La13
La14

The csv was created. Let’s take a quick look at it:

La15

Brilliant, our users were successfully exported. It’s time to conclude

7.

Conclusion

If you made it so far, I’m proud of you. It was a long journey but it was worth it. Permissions, laravel nova and policies fit perfectly together. They are secure and reliable, you can customize them just as you want. Feel free to tinker with the code as you like. Enjoy a good evening with your dearest ones, make something meaningful with your life and as always, happy coding! 

Useful links:

https://laravel.com/docs/10.x/authorization#writing-policies
https://nova.laravel.com/
https://nova.laravel.com/docs/4.0/installation.html
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