Laravel API 認可機能(ACL)を実装する

LaravelでRole(役割)、Permission(権限)によるAccess Control List(アクセス制御リスト)の機能を実装します。

## ポエム: マルチログインに注意

「Laravel マルチログイン」で調べるとLaravelのガードにadminを追加して、テーブルにadmin_usersを追加して認証と認可を表現するパターンを多く目にします。

認証と認可を1つのテーブルで表現してしまうと保守性が著しく低下してしまいます。

例えば認可の格上げや格下げができないですし、新しい認可を追加するたびにガードやテーブルが増えて複雑性が増します。
また、認証ユーザーの一覧が欲しいときに一発でユーザー一覧を取得できないので複数クエリーを発行する必要がでてきます。

認証と認可を一緒にしても実装は可能だったり導入コストは軽いかもしれません。ただし、あとから認可処理を差し替えるのは莫大なコストがかかるため検討に検討を重ねてから採用してください。

ということで今回はRole&Permissionの認可処理を実装する方法をご紹介します。
ちなみにLaravelのガードの種類を追加したい時はあるのか？ということですが複数の異なる認証方式を採用したいときに活用すると良いでしょう。

https://qiita.com/kawasima/items/8dd7eda743f2fdcad78e

https://note.com/koji_matae/n/n613677a9999c

## 利用ライブラリ

- https://github.com/spatie/laravel-permission
- https://spatie.be/docs/laravel-permission/v5/introduction

laravel-permissionは認可用のライブラリです。

認可を自前で実装してもいいですが、実装コストは少なくないですしセキュリティリスクも気になるので多くの人が利用している認可ライブラリに乗っかるのがベストプラクティスかなと考えています。

## 環境

- PHP: 8.1.5
- Laravel: 9.9.0
- laravel-permission: 5.5.2

## laravel-permissionのインストール

```shell-session
$ composer require spatie/laravel-permission
```

```php:config/app.php
'providers' => [
    // ...
    Spatie\Permission\PermissionServiceProvider::class,
];
```

```shell-session
$ php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
```

下記のファイルが生成されます。

- [database/migrations/2022_05_06_102702_create_permission_tables.php](https://github.com/spatie/laravel-permission/blob/main/config/permission.php)
- [config/permission.php](https://github.com/spatie/laravel-permission/blob/main/database/migrations/create_permission_tables.php.stub)

```shell-session
$ php artisan migrate
```

マイグレーションを実行すると下記のテーブルが生成されます。

- model_has_permissions
- model_has_roles
- permissions
- role_has_permissions
- roles

```php:app/Models/User.php
use Spatie\Permission\Traits\HasRoles;

final class User extends Authenticatable
{
    use HasRoles;

    // ...
}
```

Userモデルに `HasRoles` トレイトを追記します。

## laravel-permissionの基本的な使い方

```php
// ユーザーに権限を追加
$user->givePermissionTo('edit articles');

// ユーザーにロールを追加
$user->assignRole('writer');

// ロールに権限を追加
$role->givePermissionTo('edit articles');

// 'edit articles' 権限を持っているか
$user->can('edit articles');
```

すべての権限はLaravelの[Gate](https://readouble.com/laravel/9.x/ja/authorization.html)に登録されます。
そのため、 `$user->can(...)` が使えます。

## 実装手順

### ロール、パーミッションの定義

PHP8.1からEnumが実装されているのでこの機能を使ってみます。
一応Enumの記事も書いてます。

https://qiita.com/ucan-lab/items/e9f53aa024ca3cc5ea1b

```shell-session
$ mkdir app/Enums
$ touch app/Enums/{Role.php,Permission.php}
```

```php:app/Enums/Role.php
<?php

declare(strict_types=1);

namespace App\Enums;

enum Role: string
{
    case Staff = 'staff';
    case Manager = 'manager';
    case Developer = 'developer';
}
```

```php:app/Enums/Permission.php
<?php

declare(strict_types=1);

namespace App\Enums;

enum Permission: string
{
    case EditArticles = 'edit articles';
    case DeleteArticles = 'delete articles';
    case PublishArticles = 'publish articles';
    case UnpublishArticles = 'unpublish articles';
}

```

ロールやパーミッションは任意の名前で自由に定義して良いです。

## 開発者権限はすべての権限を使用できる

動作確認のためにはすべての権限を実行できるロールが欲しくなります。
`Role::Developer` は何でも実行できるチーター権限にしたいです。

Developerロールに対してすべてのパーミッションを一つずつ設定していくこともできますが、毎回設定するのは面倒なのでサービスプロバイダーで設定できます。

```php:app/Providers/AuthServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Enums\Role;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;

final class AuthServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Gate::before(fn ($user) => $user->hasRole(Role::Developer->value) ? true : null);
    }
}
```

詳しくは下記の公式ドキュメントを参照ください。

https://spatie.be/docs/laravel-permission/v5/basic-usage/super-admin

https://laracasts.com/discuss/channels/laravel/policy-gets-never-called#reply=492526

## ロールとパーミッションのシーダーを作成する

ロールとパーミッションの作成とロール＆パーミッションの組み合わせを作成するシーダーを実装します。

```shell-session
$ php artisan make:seeder RolesAndPermissionsSeeder
```

```php
<?php

declare(strict_types=1);

namespace Database\Seeders;

use App\Enums\Permission;
use App\Enums\Role;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission as EloquentPermission;
use Spatie\Permission\Models\Role as EloquentRole;
use Spatie\Permission\PermissionRegistrar;

final class RolesAndPermissionsSeeder extends Seeder
{
    /**
     * @param PermissionRegistrar $permissionRegistrar
     */
    public function __construct(private PermissionRegistrar $permissionRegistrar)
    {
    }

    public function run(): void
    {
        $this->permissionRegistrar->forgetCachedPermissions();

        foreach (Permission::cases() as $permission) {
            EloquentPermission::create(['name' => $permission->value]);
        }

        foreach (Role::cases() as $role) {
            EloquentRole::create(['name' => $role->value]);
        }

        foreach ($this->makeAuthorizationRuleList() as $roleName => $permissionNameList) {
            $role = EloquentRole::findByName($roleName);

            foreach ($permissionNameList as $permissionName) {
                $role->givePermissionTo($permissionName);
            }
        }
    }

    /**
     * @return array
     */
    private function makeAuthorizationRuleList(): array
    {
        return [
            Role::Staff->value => [
                Permission::EditArticles->value,
                Permission::DeleteArticles->value,
            ],
            Role::Manager->value => [
                Permission::PublishArticles->value,
                Permission::UnpublishArticles->value,
            ],
        ];
    }
}
```

`makeAuthorizationRuleList` メソッドにロールとパーミッションの組み合わせを定義します。

```shell-session
$ php artisan make:seeder UsersSeeder
```

全テーブルをtruncateするシーダーを作っておくと開発するときに便利です。

https://qiita.com/ucan-lab/items/55ec5c341fb5c228fe14

```
$ php artisan db:seed
```

## 動作確認

```shell-session
$ php artisan tinker

use App\Enums\Role;
use App\Enums\Permission;
use App\Models\User;

$staff = User::where('name', 'user1')->first();
$staff->hasRole(Role::Staff->value);
=> true

$staff->can(Permission::EditArticles->value);
=> true

$staff->can(Permission::PublishArticles->value);
=> false

$manager = User::where('name', 'user2')->first();
$manager->hasRole(Role::Manager->value);
=> true

$manager->can(Permission::EditArticles->value);
=> false

$manager->can(Permission::PublishArticles->value);
=> true

$developer = User::where('name', 'user3')->first();
$developer->hasRole(Role::Developer->value);

$developer->can(Permission::EditArticles->value);
=> true

$developer->can(Permission::PublishArticles->value);
=> true
```

## コントローラの実装例

```php:
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Enums\Permission;
use App\Http\Controllers\Controller;
use App\Models\Article;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Spatie\Permission\Exceptions\UnauthorizedException;

final class PublishArticlesController extends Controller
{
    /**
     * @param Request $request
     * @param Article $article
     * @return JsonResponse
     */
    public function __invoke(Request $request, Article $article): JsonResponse
    {
        $user = $request->user();

        if ($user->can(Permission::PublishArticles->value) === false) {
            throw UnauthorizedException::forPermissions([Permission::PublishArticles->value]);
        }

        // 記事を公開する処理
        
        return new JsonResponse([
            'message' => 'The article has been published.',
        ]);
    }
}
```

laravel-permission に例外クラスが用意されています。
https://github.com/spatie/laravel-permission/tree/main/src/Exceptions

今回の例では権限がなかった場合は `UnauthorizedException` の例外を発生させています。
この例外が発生すると下記の結果になります。

```json
{
    "message": "User does not have the right permissions."
}
```

何のパーミッションエラーなのか分からないですが、下記の設定を有効化すると何の権限が足りないかメッセージが表示されます。

```php:config/permission.php
    /*
     * When set to true, the required permission names are added to the exception
     * message. This could be considered an information leak in some contexts, so
     * the default setting is false here for optimum safety.
     */

    'display_permission_in_exception' => true,
```

変更すると下記の結果が返ります。

```json
{
    "message": "User does not have the right permissions. Necessary permissions are publish articles"
}
```

## 補足: チーム権限

本記事では省きましたが、チーム権限の概念をlaravel-permissionでサポートしています。グループや組織といった機能が必要な場合は導入を検討してください。

https://spatie.be/docs/laravel-permission/v5/basic-usage/teams-permissions

## さいごに

laravel-permissionは他にも便利な書き方や使い方がありますが、複雑な仕様でなければこの記事で書いた内容でほぼほぼ完結するかと思います。

あんまりAPI関係ない記事でしたが、参考になればと思います。