Laravel Vue SPA: Roles and Permissions Example with CASL

Laravel Vue SPA: Roles and Permissions Example with CASL
Admin
Tuesday, May 16, 2023 7 mins to read
Share
Laravel Vue SPA: Roles and Permissions Example with CASL

In this tutorial, we will show how to add permissions to the Laravel application with Vue.js SPA architecture. For the example, we will take a basic CRUD of posts, create two roles (admin and editor), and the editor role will not be able to delete the posts.

  • For the back-end, we will use Laravel Gates
  • For the front-end, we will use the CASL Vue package.
  • Also, for Vue.js we will use the <script setup> Composition API method with Composables

vue.js permissions

Let's dive in!


Permissions, Roles, Gates: Back-End

First, we will create the models with migrations for roles and permissions.

php artisan make:model Role -m
php artisan make:model Permission -m

database/migrations/xxxx_create_roles_table.php:

public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}

app/Models/Role.php:

class Role extends Model
{
protected $fillable = ['name'];
}

database/migrations/xxxx_create_permissions_table.php:

public function up(): void
{
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}

app/Models/Permission.php:

class permissions extends Model
{
protected $fillable = ['name'];
}

Next, we need to create a pivot table for the many-to-many relationship and add relations to the models.

php artisan make:migration "create permission role table"

database/migrations/xxxx_create_permission_role_table.php:

public function up(): void
{
Schema::create('permission_role', function (Blueprint $table) {
$table->foreignId('permission_id')->constrained();
$table->foreignId('role_id')->constrained();
});
}

app/Models/Role.php:

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Role extends Model
{
protected $fillable = ['name'];
 
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class);
}
}
php artisan make:migration "create role user table"

database/migrations/xxxx_create_role_user_table.php:

public function up(): void
{
Schema::create('role_user', function (Blueprint $table) {
$table->foreignId('role_id')->constrained();
$table->foreignId('user_id')->constrained();
});
}

app/Models/User.php:

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class User extends Authenticatable
{
// ...
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class);
}
}

Next, we need will create seeders to create roles with permissions and attach them.

php artisan make:seeder PermissionSeeder
php artisan make:seeder RoleSeeder
php artisan make:seeder UserSeeder

database/seeders/PermissionSeeder.php:

class PermissionSeeder extends Seeder
{
public function run(): void
{
Permission::create(['name' => 'posts.create']);
Permission::create(['name' => 'posts.update']);
Permission::create(['name' => 'posts.delete']);
}
}

database/seeders/RoleSeeder.php:

class RoleSeeder extends Seeder
{
public function run(): void
{
$admin = Role::create(['name' => 'Administrator']);
$admin->permissions()->attach(Permission::pluck('id'));
 
$editor = Role::create(['name' => 'Editor']);
$editor->permissions()->attach(
Permission::where('name', '!=', 'posts.delete')->pluck('id')
);
}
}

database/seeders/UserSeeder.php:

class UserSeeder extends Seeder
{
public function run(): void
{
$admin = User::factory()->create(['email' => 'admin@admin.com']);
$admin->roles()->attach(Role::where('name', 'Administrator')->value('id'));
 
$editor = User::factory()->create(['email' => 'editor@edit.com']);
$editor->roles()->attach(Role::where('name', 'Editor')->value('id'));
}
}

And lastly, we need to call them in the main DatabaseSeeder.

database/seeders/DatabaseSeeder.php:

class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
PermissionSeeder::class,
RoleSeeder::class,
UserSeeder::class,
]);
}
}

Run the migrations and seed the DB.

php artisan migrate --seed

Next, we need to register every permission to the Gate. This usually is done in the AuthServiceProvider.

app/Providers/AuthServiceProvider.php:

use App\Models\Permission;
use Illuminate\Support\Facades\Gate;
use Illuminate\Database\QueryException;
use Illuminate\Database\Eloquent\Builder;
 
class AuthServiceProvider extends ServiceProvider
{
// ...
public function boot(): void
{
try {
foreach (Permission::pluck('name') as $permission) {
Gate::define($permission, function ($user) use ($permission) {
return $user->roles()->whereHas('permissions', function (Builder $q) use ($permission) {
$q->where('name', $permission);
})->exists();
});
} catch (QueryException $e) {
 
}
}
}

We define all of the permissions for the gate. The Gate::define accepts closure where we define what needs to be true. In our case, we check if any of the user's roles have the permissions.

The try/catch is needed because at first we don't have a permissions table and even running migrations will result in error message:

Illuminate\Database\QueryException
 
SQLSTATE[42S02]: Base table or view not found: 1146 Table 'project.permissions' doesn't exist (Connection: mysql, SQL: select `name` from `permissions`)
 
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:793
789▕ // If an exception occurs when attempting to run a query, we'll format the error
790▕ // message to include the bindings with SQL, which will make this exception a
791▕ // lot more helpful to the developer instead of just the database's errors.
792▕ catch (Exception $e) {
➜ 793▕ throw new QueryException(
794▕ $this->getName(), $query, $this->prepareBindings($bindings), $e
795▕ );
796▕ }
797▕ }
 
i A table was not found: You might have forgotten to run your database migrations.
https://laravel.com/docs/master/migrations#running-migrations
 
1 [internal]:0
Illuminate\Foundation\Application::Illuminate\Foundation\{closure}()
+13 vendor frames
 
15 app/Providers/AuthServiceProvider.php:29

Now if you would try to delete a post with the user which has editor role, you would get an error.

In the developer console, in the network tab, you would see the expected result from the response This action is unauthorized.

network tab


Permissions Front-end: Vue CASL and v-if

For front-end permissions, we will use the package CASL Vue. First, let's install it.

npm install @casl/vue @casl/ability

To get started, we need to import abilitiesPlugin and ability from the services in the main app.js.

resources/js/app.js:

import './bootstrap';
 
import { createApp } from 'vue'
 
import router from './routes/index'
import { abilitiesPlugin } from '@casl/vue';
import ability from './services/ability';
 
createApp()
.use(router)
.use(abilitiesPlugin, ability)
.mount('#app')

Ant what is inside this /services/ability? You define the abilities there, and one of the sections in the documentation is about ability builder. And we can copy the code in that services file.

resources/js/services/ability.js:

import { AbilityBuilder, Ability } from '@casl/ability'
 
const { can, cannot, build } = new AbilityBuilder(Ability);
 
export default build();

But instead of defining can and cannot here, we will define them based on the API call to the /abilities API endpoint. Now let's build the /abilities API route.

routes/api.php:

Route::group(['middleware' => 'auth:sanctum'], function() {
// ...
 
Route::get('abilities', function(Request $request) {
return $request->user()->roles()->with('permissions')
->get()
->pluck('permissions')
->flatten()
->pluck('name')
->unique()
->values()
->toArray();
});
});

We get the authenticated user's roles with permissions. Then we pluck to have only permissions, and using other Collection methods, we get the unique permissions in the array list.

Now we need to use this API call in the auth Composable. For this, we will create a specific method getAbilities().

resources/js/composables/auth.js:

import { ref, inject } from 'vue'
import { useRouter } from 'vue-router';
import { AbilityBuilder, Ability } from '@casl/ability';
import { ABILITY_TOKEN } from '@casl/vue';
 
export default function useAuth() {
const ability = inject(ABILITY_TOKEN)
 
// ...
 
const getAbilities = async() => {
axios.get('/api/abilities')
.then(response => {
const permissions = response.data
const { can, rules } = new AbilityBuilder(Ability)
 
can(permissions)
 
ability.update(rules)
})
}
 
return {
getAbilities
}
}

After a successful HTTP GET request, we assign all the permissions to a variable from the response. Then we add all the permissions into a can method and update the abilities.

To understand more about how this package works refer to the documentation.

And we call that getAbilities in the loginUser before router.push. And because we did await for the getAbilities now the loginUser becomes async and also we need to add await for the router.push.

Next, we need to call getAbilities method when user logsin. In this tutorial this method is loginUser in the auth Composable. This method needs to be async method and the ones that are getting called inside, like getAbilities, needs to have await.

import { ref, reactive, inject } from 'vue'
import { useRouter } from 'vue-router';
import { AbilityBuilder, Ability } from '@casl/ability';
import { ABILITY_TOKEN } from '@casl/vue';
 
const user = reactive({
name: '',
email: '',
})
 
export default function useAuth() {
// ...
const loginUser = async (response) => {
user.name = response.data.name
user.email = response.data.email
 
localStorage.setItem('loggedIn', JSON.stringify(true))
await getAbilities()
await router.push({ name: 'posts.index' })
}
// ...
}

And finally, we can hide the Edit and Delete actions in the posts list Vue.js component if the user doesn't have permission for that action.

The syntax for the v-if directive is v-if="can('permission name here')".

resources/js/components/Posts/Index.vue:

<template>
<div class="overflow-hidden overflow-x-auto p-6 bg-white border-gray-200">
<div class="min-w-full align-middle">
<table class="min-w-full divide-y divide-gray-200 border">
// ...
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
<tr v-for="post in posts.data">
// ...
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-900">
<router-link v-if="can('posts.update')" :to="{ name: 'posts.edit', params: { id: post.id } }">Edit</router-link> // [tl! ++]
<a href="#" v-if="can('posts.delete')" @click.prevent="deletePost(post.id)" class="ml-2">Delete</a> // [tl! ++]
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
 
<script setup>
// ...
</script>

But we don't have that can defined yet! Let's add that.

resources/js/components/Posts/Index.vue:

<script setup>
import { onMounted, ref, watch } from "vue";
import usePosts from "@/composables/posts";
import { useAbility } from '@casl/vue'
 
const { can } = useAbility()
 
// ...
</script>

Now, if you log in with the editor role, you should see that the Delete action isn't showed anymore.

delete action doesn't show