Honeybadger Staff
Posted on June 15, 2022
This article was originally written by Ashley Allen on the Honeybadger Developer Blog.
In the web development world, you'll often come across the terms "roles" and "permissions", but what do these mean? A permission is the right to have access to something, such as a page in a web app. A role is just a collection of permissions.
To give this a bit of context, let's take a simple example of a content management system (CMS). The system could have multiple basic permissions, including the following:
- Can create blog posts
- Can update blog posts
- Can delete blog posts
- Can create users
- Can update users
- Can delete users
The system could also have roles, such as the following:
- Editor
- Admin
So, we could assume that the 'Editor' role would have the 'can create blog posts', 'can update blog posts', and 'can delete blog posts' permissions. But, they wouldn't have the permissions to create, update, or delete users, whereas an admin would have all of these permissions.
Using roles and permissions like those listed above is a great way to build a system with the ability to limit what a user can see and do.
How to Use the Spatie Laravel Permissions Package
There are different ways to implement roles and permissions in your Laravel app. You could write the code yourself to handle the entire concept. However, this can sometimes be very time-consuming, and in most cases, using a package is more than sufficient.
In this article, we'll be using the Laravel Permission package from Spatie.
Installation and Configuration
To get started with using the package, we'll install it using the following command:
composer require spatie/laravel-permission
Now that we've installed the package, we'll need to publish the database migration and config file:
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
We can now run the migrations to create the new tables in our database:
php artisan migrate
Assuming that we are using the default config values and haven't changed anything in the package's config/permission.php
, we should now have five new tables in our database:
-
roles
- This table will hold the names of the roles in your app. -
permissions
- This table will hold the names of the permissions in your app. -
model_has_permissions
- This table will hold data showing which permissions your models (e.g.,User
) have. -
model_has_roles
- This table will hold data showing which roles your models (e.g.,User
) have. -
role_has_permissions
- This table will hold data showing the permissions that each role has.
To finish the basic installation, we can now add the HasRoles
trait to our model:
use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasRoles;
// ...
}
Creating Roles and Permissions
To get started with adding our roles and permissions to our Laravel application, we'll need to first store them in the database. It's simple to create a new role or permission because, in Spatie's package, they're just models: Spatie\Permission\Models\Role
and Spatie\Permission\Models\Permission
.
So, this means that if we want to create a new role in our system, we can do something like the following:
$role = Role::create(['name' => 'editor']);
We can create permissions in a similar way:
$permission = Permission::create(['name' => 'create-blog-posts']);
In most cases, you'll define the permissions in your code rather than let your application's users create them. However, you'll likely take a slightly different approach with the roles. You might want to define all the roles yourself in your codebase and not give your users any ability to create new ones. On the other hand, you could create some "seeder" roles yourself (e.g., Admin) and then provide your users with the functionality to add new ones. This decision mainly comes down to what you're trying to achieve with your system and who the end users are.
If you want to add any default roles and permissions to your application, you can add them using database seeders. You'll probably want to create a seeder specifically for this task (maybe called something like RoleAndPermissionSeeder
). So, let's start by making the new seeder using the following command:
php artisan make:seeder RoleAndPermissionSeeder
This should have created a new /database/seeders/RoleAndPermissionSeeder.php
file. Before we make any changes to this file, we need to remember to update our database/seeders/DatabaseSeeder.php
so that it automatically calls our new seed file whenever we use the php artisan db:seed
command:
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run()
{
// ...
$this->call([
RoleAndPermissionSeeder::class,
]);
// ...
}
}
Now, we can update our new seeder to add some default roles and permissions to our system:
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleAndPermissionSeeder extends Seeder
{
public function run()
{
Permission::create(['name' => 'create-users']);
Permission::create(['name' => 'edit-users']);
Permission::create(['name' => 'delete-users']);
Permission::create(['name' => 'create-blog-posts']);
Permission::create(['name' => 'edit-blog-posts']);
Permission::create(['name' => 'delete-blog-posts']);
$adminRole = Role::create(['name' => 'Admin']);
$editorRole = Role::create(['name' => 'Editor']);
$adminRole->givePermissionTo([
'create-users',
'edit-users',
'delete-users',
'create-blog-posts',
'edit-blog-posts',
'delete-blog-posts',
]);
$editorRole->givePermissionTo([
'create-blog-posts',
'edit-blog-posts',
'delete-blog-posts',
]);
}
}
Assigning Roles and Permissions to Users
Now that we have our roles and permissions in our database and ready to be assigned, we can look at how we can assign them to our users.
First, let's look at how simple it is to assign a new role to a user:
$user = User::first();
$user->assignRole('Admin');
We can also give permissions to that role so that the user will also have that permission:
$role = Role::findByName('Admin');
$role->givePermissionTo('edit-users');
It's possible that you might provide the functionality in your application for permissions to be assigned directly to users, as well as (or instead of) assign them to roles. The code snippet below shows how we can do this:
$user = User::first();
$user->givePermissionTo('edit-users');
Along with being able to assign roles and permissions, you'll need to provide the functionality to remove roles and revoke permissions from users. Here's a quick look at how easy it is to remove a role from a user:
$user = User::first();
$user->removeRole('Admin');
We can also remove permissions from users and roles in a similar way:
$role = Role::findByName('Admin');
$role->revokePermissionTo('edit-users');
$user = User::first();
$user->revokePermissionTo('edit-users');
Restricting Access Based on Permissions
Now that we've got our roles and permissions stored in our database and know how to assign them to our users, we can take a look at how to add authorization checks.
The first way that you might want to add authorization would be through using \Illuminate\Auth\Middleware\Authorize
middleware. This comes default in fresh Laravel installations, so as long as you haven't removed it from your app/Http/Kernel.php
, it should be aliased to can
. So, let's imagine that we have a route that we want to restrict access to unless the authenticated user has the create-users
middleware. We could add the middleware to the individual route:
Route::get(
'/users/create',
[\App\Http\Controllers\UserController::class, 'create']
)->middleware('can:create-users');
You'll likely find that you have multiple routes that are related to each other and rely on the same permission. In this case, your routes file might get a bit messy due to assigning the middleware on a route-by-route basis. So, you can add the authorization by adding the middleware to a route group instead:
Route::middleware('create-users')->group(function () {
Route::get(
'/users/create',
[\App\Http\Controllers\UserController::class, 'create']
);
Route::post(
'/users',
[\App\Http\Controllers\UserController::class, 'store']
);
});
It's worth noting that if you prefer to define your middleware in your controller constructors, you can also use the can
middleware there. You might also want to make use of ->authorize()
in your controller methods of using the middleware. Using this method will require you to create policies for your models, but if used properly, this technique can be really useful for keeping your authorization clean and understandable.
You might find in your application that you sometimes need to manually check whether a user has a specific permission but without denying access completely. We can do this using the ->can()
method on the User
model.
For example, let's imagine that we have a form in our application that allows a user to update their name, email address, and password. Now, let's say that we want to give users with the 'Editor' role permission to edit users, but not to change another user's password. We'll only allow users to update another user's password if they also have the edit-passwords
permission.
We'll assume in our example below that we are using middleware to only allow users with the edit-users
permission to access this method. Let's take a look at how we could implement this in our controller:
namespace App\Http\Controllers;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use Illuminate\View\View;
class UserController extends Controller
{
// ...
public function update(UpdateUserRequest $request, User $user): View
{
$user->name = $request->name;
$user->email = $request->email;
if (auth()->user()->can('edit-passwords')) {
$user->password = $request->password;
}
$user->save();
return view('users.show')->with([
'user' => $user,
]);
}
// ...
}
Showing and Hiding Content in Views Based on Permissions
It's likely that you'll want to be able to show and hide parts of your views based on a user's permissions. For example, let's imagine that we have a basic button in our Blade view that we can press to delete a user. Let's also say that the button should only be shown if the user has the delete-users
permission.
To show and hide this button, it's super simple! We can use the@can()
Blade directive:
@can('delete-users')
<a href="/users/1/destroy">Delete</a>
@endcan
If the user has the delete-users
permission, anything inside the @can()
and @endcan
will be displayed. Otherwise, it won't be rendered in the Blade view as HTML.
It's important to remember that hiding buttons, forms, and links in your views doesn't provide any server-side authorization. You'll still need to add authorization to your backend code (e.g., in your controllers or using middleware as explained above) to prevent malicious users from making any requests to routes that should only be available to users with specific permissions.
How to Add a "Super Admin" Permission
When you create an application, you might want to add a "super admin" role. A perfect example for this could be that you offer a software as a service (SaaS) platform that is multi-tenant. You might want employees of your company to be able to move around the entire application and view different tenant's systems (maybe for debugging and answering support tickets).
Before we add the super admin check, it'd probably be worthwhile to take a quick look at how Spatie's package uses gates in Laravel. In case you haven't already come across them, gates are really simple; they're just "closures that determine if a user is authorized to perform a given action".
When you use a piece of code like $user->can('delete-users')
, you're using Laravel's gates.
Before any gates are run to check a permission, we can run code that we define in a before()
method. If any of the before()
closures that are run return true
, the user is allowed access. If a before()
closure returns false, it denies access. If it returns null
, Laravel will proceed and run any outstanding before()
closures and then check the gate itself.
In the \Spatie\Permission\PermissionRegistrar
class in the package, we can see that our permission check is added as before()
to run before the gate. If the package determines that the user has the permission (either assigned directly or through a role), it will return true
. Otherwise, it will return null
so that any other before()
closures can be run.
So, we can use this same approach to add a super admin role check to our code. We can add the code to our AuthServiceProvider
:
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
public function boot()
{
// ...
Gate::before(function ($user, $ability) {
return $user->hasRole('super-admin') ? true : null;
});
/// ...
}
}
Now, whenever we run a line of code like $user->can('delete-users')
, we will be checking whether the user has the delete-users
permission or the super-admin
role. If at least one of the two criteria is satisfied, the user will be allowed access. Otherwise, the user will be denied access.
How to Test Permissions and Access
Having an automated test suite that covers your authorization can be extremely handy! It helps to give you the confidence that you're protecting your routes properly and that only users with the correct permissions can access certain features.
To see how we can write a test for this, we'll start by imagining a simple system that we can write tests for. The tests will only be super basic and can definitely be stricter, but it will hopefully give you an idea of the basic concept of permissions testing.
Let's say that we have a CMS that has two default roles: 'Admin' and 'Editor'. We'll also assume that our system doesn't allow assigning permissions directly to a user. Instead, the permissions can only be assigned to roles, and the user can then be assigned one of them roles.
Let's say that by default, the 'Admin' role has permission to create/update/delete users and create/update/delete blog posts. Let's say that the 'Editor' role only has permission to create/update/delete blog posts.
Now, let's take this basic example route and controller that we could go to for creating a new user:
Route::get(
'/users/create',
[\App\Http\Controllers\UserController::class, 'create']
)->middleware('can:create-users');
namespace App\Http\Controllers;
use Illuminate\View\View;
class UserController extends Controller
{
// ...
public function create(): View
{
return view('users.create');
}
// ...
}
As you can see, we've added authorization to the route so that only users with the create-users
permission are allowed access.
Now, we can write our tests:
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class UserControllerTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Role $role;
protected function setUp()
{
parent::setUp();
$this->user = User::factory()->create();
$this->role = Role::create(['name' => 'custom-role']);
$this->user->assignRole($this->role);
$this->role->givePermissionTo('create-users');
}
/** @test */
public function view_is_returned_if_the_user_has_permission()
{
$this->actingAs($this->user)
->get('/users/create')
->assertOk();
}
/** @test */
public function access_is_denied_if_the_user_does_not_have_permission()
{
$this->role->revokePermissionTo('create-users');
$this->actingAs($this->user)
->get('/users/create')
->assertForbidden();
}
}
Bonus Tips
If you’ll be creating the permissions yourself and not letting your users create them, it can be quite useful to store your permission and role names as constants or enums. For example, for defining your permission names, you could have a file like this:
namespace App\Permissions;
class Permission
{
public const CAN_CREATE_BLOG_POSTS = 'create-blog-posts';
public const CAN_UPDATE_BLOG_POSTS = 'update-blog-posts';
public const CAN_DELETE_BLOG_POSTS = 'delete-blog-posts';
public const CAN_CREATE_USERS = 'create-users';
public const CAN_UPDATE_USERS = 'update-users';
public const CAN_DELETE_USERS = 'delete-users';
}
By using a file like this, it can make it much easier to avoid any spelling mistakes that might cause any unexpected bugs. For example, let's imagine that we have a permission called create-blog-posts
and that we have this line of code:
$user->can('create-blog-post');
If you were reviewing this code in a pull request or writing it yourself, I wouldn't blame you for thinking that it is valid. However, we've omitted the s
from the end of the permission! So, to avoid this problem, we could use the following:
use App\Permissions\Permission;
$user->can(Permission::CAN_CREATE_BLOG_POSTS);
Now, we have more confidence that the permission name is correct. As an extra bonus, this also makes it super easy if you want to see anywhere that this permission is used, because your IDE (e.g., PHPStorm) should be able to detect which files it's being used in.
Alternative Packages and Approaches
As well as using Spatie's Laravel Permission package, there are other packages that can be used to add roles and permissions to your application. For example, you could use Bouncer or Laratrust.
You might find that you need more bespoke functionality and flexibility in some of your applications than the packages provide. In this case, you might need to write your own roles and permissions implementation. A good starting point for this would be to use Laravel's 'Gates' and 'Policies', as mentioned earlier.
Conclusion
Hopefully, this article has given you an overview of how to add permissions to your Laravel applications using Spatie's Laravel Permission package. It should have also given you insight into how to can write automated tests in PHPUnit to test that your permissions are set up correctly.
Posted on June 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 20, 2024