How to Integrate Laravel Sanctum with Spatie Permissions
Use a single source of truth for your user permissions. One rules set to rule them all.
Written by Chris Arter | Published onUse a single source of truth for your user permissions. One rules set to rule them all.
Written by Chris Arter | Published onIn this article, I’m going to show you a simplified setup of how I’ve used Spatie Roles & Permissions package with Laravel Sanctum. This will allow users Sanctum tokens to respect the user’s abilities through this package.
Roles and permissions is a widely used package for determining authorization within your application. I treat a user’s abilities through its role as the source of truth for what this user can or cannot do.
By default, sanctum allows for *
ability, which means this token has cart blanche across any middleware checks.
You can also pass explicit permissions to the token:
$user->createAccessToken('some-token', ['view:protected-rotue'])->plainTextToken
The abilities of a token are specific to that given token, so regardless of the user the token belongs to, the token can do anything in its capabilities column.
This introduces scenarios where we may not give a user the permission to do an action, but in creating a token for this user, they may be able to perform those actions through their API token.
In effect, we’d be maintaining two authorization systems. Life is too short to work that hard.
💡
By default, abilities are stored in the abilities
column in the personal access tokens
table.
Basically, we’re going to override how Laravel’s default PersonalAccessToken
class looks up a token’s capabilities and defer to its tokenable
model’s capabilities. The tokenable
model is our User
model by default.
To begin, you should have both Spatie Roles & Permissions installed, as well as Laravel Sanctum.
During installation, make sure you:
Apply the HasRoles
and HasApiTokens
traits to the User model.
Publish the configurations and run all applicable migrations.
If your user model is using UUIDs, then be sure to configure your migrations.
The Sanctum docs detail how to override the Personal Access Token model here.
Following the same pattern, create a new file in /app/Models/CustomToken.php
This is the new model Sanctum will use to manage tokens.
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class CustomToken extends SanctumPersonalAccessToken
{
public $table = 'personal_access_tokens';
public function can($ability)
{
return $this->tokenable->can($ability);
}
}
This class is overriding the can()
method. Originally, the method does a look up on the token’s abilities column. Our class will proxy the same ability check, but against our user model’s permissions according to the Spatie permissions package.
Next, we need to tell Sanctum to override the default model for our Custom token.
<?php
namespace App\Providers;
use App\Models\CustomToken;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(CustomToken::class);
}
}
Here is a basic setup for seeing the same rule set in action across web guard and sanctum.
Our web.php
routes file could look like this:
Route::get('/protected', function () {
Route::get('/protected', function () {
return 'This is protected';
})->middleware('auth', 'can:view-protected');
And your api.php
route looks like this:
Route::get('/protected', function () {
return 'This is protected by sanctum';
})->middleware(['auth:sanctum', 'can:view-protected'])
->name('api.protected');
If you do not have a /routes/api.php
file yet, first create a /routes/api.php
file. Then, in your bootstrap/app.php
file, you can add it here:
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
api: __DIR__.'/../routes/api.php', // 👈 Add this
health: '/up',
)
Next, we can try out our new setup using some tests below.
<?php
use App\Models\User;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use function Pest\Laravel\get;
beforeEach(function () {
$this->role = Role::create(['name' => 'admin']);
$this->permission = Permission::create(['name' => 'view-protected']);
$this->role->givePermissionTo($this->permission);
$this->route = route('api.protected');
});
test('user can access protected route', function () {
$user = User::factory()
->create()
->assignRole($this->role);
$this->actingAs($user)
->get('/protected')
->assertStatus(200);
});
test('user cannot access protected route', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/protected')
->assertStatus(403);
});
test('Spatie permissions are proxied to token abilities', function () {
$token = User::factory()
->create()
->givePermissionTo('view-protected')
->createToken('test-token')
->plainTextToken;
get(route('api.protected'), ['Authorization' => 'Bearer '.$token])
->assertOk(200);
});
In the tests above, we’re verifying that a user can access the same respective abilities in both the API as well as the web guard (our web app).
In a tradition as old as time, when figuring out if this pattern makes sense for your application, the answer is always it depends. I find this approach works brilliantly if I have a both a user-facing dashboard, as well as a public API, and I want to create a simple token that allows users to do exactly what they can do in the admin. However, there are trade offs. In our simpler approach, we don’t account for allowing users to create more granular-permission tokens, which violates the Principle of least privilege.
If a user wants to be more granular with token scopes, we can alter our approach to allow a layered permission check. In the class below, this would allow the User
to create either tokens with specific permissions, or defer to the inherited permissions of the user.
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class CustomToken extends SanctumPersonalAccessToken
{
public $table = 'personal_access_tokens';
public function can($ability)
{
// if the user can't do it, our token can't either.
if(!$this->tokenable->can($ability)) {
return false;
}
// no wildcards. sorry Charlie.
$abilities = collect($this->abilities)->filter(function($ability) {
return $ability !== '*';
})->toArray();
// we have explicit permissions passed to `createAccessToken`
if(count($abilities) > 0) {
return $this->canDb($abilities);
}
return true;
}
protected function canDb($ability): bool
{
return array_key_exists($ability, array_flip($this->abilities));
}
}
If you are a visual learner, I recorded this video just for you :)
https://www.youtube.com/watch?v=YX_X-kYLN8c