💭

Laravel 権限管理を実装してみた話

2021/09/10に公開

とあるプロジェクトで、ユーザーの権限を画面で管理したい、との要望がありました。

要は何か権限の一覧があって、横にチェックボックスにチェックを入れるか外すかによって権限を管理すると。

画面だけなら簡単ですが、それを実際にルートの保護、表示内容の保護に使うのであれば、もっと深入りする必要があります。

事前準備

ユーザー、役割と権限の関係について

全て話のベースとなるため、先に明示した方が良いかと思います。実際のプロジェクトによって違うと思いますが、今回は以下の関係で実装しました。

まず、ユーザーとロール・役割はone-to-manyの関係となっています。つまり、一つの役割には複数のユーザーを持つ、一人のユーザーは一つの役割しか担わない、との構造です。これは、今回のプロジェクトにおける役割・ロール自身の排他性によってone-to-manyの関係性が決められています。例えば、ユーザーAの役割が管理者なら、同時に一般ユーザーであるわけがないです。

ただ、一部のシステムでは、userとroleをmany-to-manyの関係で定義し、お互いに複数持つことが可能になっています。これは、排他性が厳密的ではない場合、many-to-manyにすることも可能とのことです。例えばユーザーBの役割がチームリーダーと同時に、プレーヤーの一員でもあるとか、VIPユーザーが同時にメンバーユーザーでもあるなどの例が考えられます。

次にロールと権限の関係はmany-to-manyになっています。ロールには複数の権限を持つことが可能である同時に、権限も複数のロールに属することが可能と。そのため、仲介役のpivotテーブルが必要になってきます。プライムキーが二つの外部キーからの複合キーとなります:

 Schema::create('roles_permissions', function (Blueprint $table) {
      $table->unsignedTinyInteger('role_id');
      $table->unsignedSmallInteger('permission_id');

      $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade');
      $table->foreign('permission_id')->references('id')->on('permissions')->onDelete('cascade');

      $table->primary(['role_id', 'permission_id']);
});

この関係性をはっきりとすれば、これからの実装が楽になります。

Eloquentで関係性を表す

非常に大事でかつ若干複雑な部分となりますが、正しく関係を定義することで、権限から該当権限を持っているロールの取得と逆のパターンができるようになります。

まずはRole.phpモデルに次の関係を追加します:


public function permissions()
{
    return $this->belongsToMany(Permission::class, 'roles_permissions', 'role_id', 'permission_id');
}

public function hasPermission(String $permission)
{
    return (bool) $this->permissions->where('name', $permission)->count();
}

hasPermissionもついでに追加して、$role->hasPermission('permission-name')で運用可能になります。

そしてPermission.phpモデルにも:

public function roles()
{
    return $this->belongsToMany(Role::class, 'roles_permissions', 'permission_id', 'role_id');
}

これで$permission->roles$role->permissionsで運用することが可能になります。いずれのPermissionまたはRoleで、属しているRoleまたはPermissionを取得。

さらにUser.phphasRolehasPermissionの判断を追加します。


public function role()
{
    return $this->belongsTo(Role::class);
}

public function hasRole(String|int $role)
{
    return ($this->role->name == $role) || ($this->role->id == $role);
}

public function hasPermission(String $permission)
{
    return (bool) $this->role->permissions->where('name', $permission)->count();
}

権限を操作するトレイト

関係性を定義した上で、権限を特定のロール・役割に付与・剥奪・リセットするメソッドを実装します。直接Role.phpファイルに書き込むのも可能だが、ここは一つのtraitとして抽出します。

namespace App\Models\Traits;

use App\Models\Permission;

trait ChangePermissions
{
    public function givePermissions(array $permissions)
    {
        $permissions = $this->getPermissions($permissions);
        if ($permissions->isEmpty()) return $this;

        foreach ($permissions as $perm) {
            if (!$this->hasPermission($perm->name)) {
                $this->permissions()->attach($perm->id);
            }
        }
        return $this;
    }

    public function removePermissions(array $permissions)
    {
        $permissions = $this->getPermissions($permissions);
	if ($permissions->isEmpty()) return $this;
	
        foreach ($permissions as $perm) {
            if ($this->hasPermission($perm->name)) {
                $this->permissions()->detach($perm->id);
            }
        }
        return $this;
    }

    public function refreshPermissions(array $permissions)
    {
        $this->permissions()->detach();
        return $this->givePermissions($permissions);
    }

    protected function getPermissions(array $permissions)
    {
        return Permission::whereIn('name', $permissions)->get();
    }
}

権限の付与はattach、剥奪はdetachで、pivotテーブルにレコードを挿入または削除します。ここで先にすでに存在するかどうかを判断しないと、pivotテーブルへ重複するレコードを挿入することになり、PKの制約に引っかかり、エラーになります。

要注意するのはrefreshPermissionsメソッドです。最初はこれで権限の更新が楽だと思いましたが、detachで該当ロールの持つ全ての権限レコードを削除してしまい、その都度新しいidを持つ権限レコードとしてDBに追加されます。そのため、これを使うと、更新が進むにつれ、権限テーブルのidがどんどん増えていくことになります。実際に権限の更新するには、giveremoveの方が無難だが、とりあえず機能として残しておきます。

// Role.php
// ロールモデルファイルに導入
use ChangePermissions;

これで任意のroleに$role->givePermissions($permissions)$role->removePermissions($permissions)とかで操作可能になります。

権限機能応用

権限関連のゲートを定義

これで事前準備は大体できました。今回の権限の応用といえば大体2つ:

  • ルートアクセス保護
  • 画面表示内容保護

ここでLaravelのゲート機能を利用します。ゲートと言われてピンとこないかもしれませんが、can('permission')という使い方が見かけると思いますが、これはまさにゲートです。ゲートはユーザーに何かの操作をするための「権限」があるかどうかをtrue/falseの判断をします。この判断結果をサービスプロバイダとして、アプリ全体に行き渡ることになるため、can('permission')があちこちに使うことができます。

もちろん、今回の目的の画面内容の保護とルート保護両方に使えますので、ここはまずゲートを定義します。

php artisan make:provider PermissionsServiceProvider
class PermissionsServiceProvider extends ServiceProvider
{
    public function boot()
    {
        try {
            Permission::get()->map(function ($perm) {
                Gate::define($perm->name, function ($user) use ($perm) {
                    return $user->hasPermission($perm->name);
                });
            });
        } catch (\Exception $e) {
            Log::error(__FILE__ . " (" . __LINE__ . ")" . PHP_EOL . $e->getMessage());
            return false;
        }

        Blade::directive('role', function ($role) {
            return "if(auth()->check() && auth()->user()->hasRole({$role})) :";
        });

        Blade::directive('endrole', function ($role) {
            return "endif;";
        });
    }
}

ゲートファサードで、権限の名前を持ってそれぞれ定義し、ユーザーがその権限を持つかどうかを判断します。要注意するのは、ゲートで定義した「権限」というのは前節で作ったPermission/権限とは違いますが、前節で作ったPermission/権限を利用しているだけです。理論上、Permissionテーブルを作らなくても、ゲートだけを定義してもcan('permission')の機能は実現可能です(ただおすすめはしません、メンテナンスの悪夢は見え見えです)。

また、ここでカスタムの命令(directive)roleを定義すると、ブレードファイルで、@role('role_id')...@endroleの形で使えます。ユーザーの役割で一括で判断する場合と、権限毎に判断する場合の使い分けになります。

最後に作動させるために、こちらのサービスプロバイダをconfig\app.phpに登録します:

'providers' => [
    //...
    App\Providers\PermissionsServiceProvider::class,
],

ミドルウェアでルート保護

これでようやく、全ての準備が整えました。

まずはルート保護の応用を見てみましょう。ルート保護のためのミドルウェアを作ります:

php artisan make:middleware PermissionMiddleware
class PermissionMiddleware
{
    public function handle($request, Closure $next, $permission = null)
    {
        if ($permission !== null && !$request->user()->can($permission)) {
            abort(403);
        }

        return $next($request);
    }
}

ゲートの定義により、can('permission-name')の形で利用可能になりましたので、保護したいルートに権限の名前を引数として渡せば機能します。このミドルウェアは、$permissionという引数を受け取り、ユーザーが該当権限を持っているかどうかにより、リクエストを続けるか、リダイレクトするかを決めます。

つまり、web.phpとかのルーティングファイルに次のように使えます:

Route::group(['middleware' => 'permission:view-users'], function () {
       Route::get('/users', [UserController::class,'index'])->name('user.index');
       Route::post('/users', [UserController::class,'store']);
       Route::get('/users/{id}', [UserController::class,'show'])->name('user.show');
});

view-usersの権限を持つユーザーのみこれらのルートにアクセス可能です。ちなみに、複数の引数を渡すのであれば、...$permissionで定義し、ルーティングファイルにはコンマをつけて、'permission:perm1,perm2,...の形で利用可能です。

もちろん、作動させるために、App\Http\Kernel.phpファイルにミドルウェアの登録も忘れずに。

protected $routeMiddleware = [
    // ...
    'permission' => \App\Http\Middleware\PermissionMiddleware::class,
];

ブレードファイルの内容保護

最後は内容保護ですが、こちらも簡単に使えます。例えば:

@canany(['view-users', 'view-items'])
  <ul class="nav flex-column nav-pills">
    @can('view-users')
      <li class="nav-item">
        ...
      </li>
    @endcan
    @can('view-items')
      <li class="nav-item">
	...
      </li>
    @endcan
  </ul>
@endcan

エレガントですね。

権限管理画面

コントローラーとルーティング

これまでにコアな部分は実装できました。最後は権限を管理できる画面を作ります。まずはコントローラーで処理を書きます。ここで少なくとも3つのルートとメソッドが必要となります:

  • 画面表示
  • ロールを切り替えるたびに該当ロールの権限を取得
  • 変更後の内容を保存する

まずはルートをつけてみると:

Route::group(['middleware' => 'permission:view-permission'], function () {
	Route::get('/permission', [PermissionController::class, 'index'])->name('perm.index');
	Route::post('/permission', [PermissionController::class, 'store']);
	Route::get('/permission/{role_id}', [PermissionController::class, 'getPerms']);
});

次にコントローラーのメソッドを書きます。今回の権限取得と内容保存を非同期処理にします。

class PermissionController extends Controller
{
    public function index(Request $request)
    {
        $perms = Permission::all();
	$roles = Role::all();
        return view('permission', compact('perms','roles'));
    }

    public function store(Request $request)
    {
        $response = ['status' => 0];

        if (!$request->has('role_id')) {
            $response['msg'] =  'ユーザー役割を選択してください。';
            return Response()->json($response);
        }

        try {
            $role = Role::find($request->get('role_id'));
	    $data = $request->except(['role_id','_token']);
	    
	    // チェックされていない項目はフォームデータとしてポストされない
            $all_perms = Permission::pluck('name')->toArray();
            $checked = array_keys($data);
            $unchecked = array_values(array_diff($all_perms, $checked));
	    
	    // idが無駄に増える問題を回避するためにrefreshPermissionを使用しない
            $role->givePermissions($checked);
            $role->removePermissions($unchecked);
        } catch (\Exception $e) {
            Log::error(__FILE__ . " (" . __LINE__ . ")" . PHP_EOL . $e->getMessage());
            $response['msg'] =  '変更が失敗しました。';
            return Response()->json($response);
        }

        $response['msg'] =  '変更が保存されました。';
        $response['status'] = 1;
        return Response()->json($response);
    }

    public function getPerms(Request $request, $role_id)
    {
        $response = ['status' => 0];

        $role = Role::find($role_id);
        if (is_null($role)) {
            $response['msg'] = 'ユーザー役割が存在しません。';
            return Response()->json($response);
        }

        $response['msg'] = '権限を取得できました。';
        $response['status'] = 1;
        $response['data'] = ['perms' => $role->permissions->pluck('name')];
        return Response()->json($response);
    }
}

ブレードビュー

次にビューの部分を実装します。bootstrap使用の一例として:

@section('content')
  <div class="container">
    <form id="permissionForm" method="post" enctype="multipart/form-data">
      @csrf
      <div class="form-group row align-items-center">
        <div class="col-md-2">
          <h5 class="mb-0 align-middle">
            <span class="badge badge-secondary px-2 py-2">ユーザー役割</span>
          </h5>
        </div>
        <div class="col-md-8 d-flex justify-content-around">
          <select class="custom-select" name="role_id" id="roleId">
            <option disabled selected>選択して下さい</option>
	    @foreach ($roles as $role)
	     <option value="{{$role->id}}">{{$role->name}}</option>
	    @endforeach
          </select>
        </div>
        <div class="col-md-2 text-center">
          <button class="btn btn-primary" id="saveBtn" type="submit" disabled>保存</button>
        </div>
      </div>

      <div class="col-md-12 px-0" id="permissionTable" hidden>
        <table class="table table-hover">
          <thead class="thead-light">
            <tr>
              <th class="text-nowrap" scope="col">&nbsp;</th>
              <th class="text-nowrap" scope="col">権限名</th>
              <th class="text-nowrap text-center" scope="col">付与</th>
            </tr>
          </thead>
          <tbody>
            @foreach ($perms as $perm)
              <tr scope="row">
                <td class="text-center">{{ $loop->iteration }}</td>
                <td class="text-nowrap">
                  {{ $perm->name }}
                </td>
                <td class="text-nowrap text-center">
                  <label for="{{ $perm->name }}" class='w-100'>
                    <input type="checkbox" name="{{ $perm->name }}" id="{{ $perm->name }}">
                  </label>
                </td>
              </tr>
            @endforeach
          </tbody>
        </table>
      </div>
    </form>
  </div>
@endsection
@section('scripts')
  <script src="{{ asset('js/perm.js') }}" defer></script>
@endsection

ここでinputname属性を全部権限の名前にしています。理由は便利だけですが、別にidとかにしても構いません。

大体の画面はこのように見えます:

JSファイル

最後は非同期処理をJSファイルに書き込み、ブレードファイルに導入します。

$(function () {

  const csrfHeader = {
    'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content'),
  }

  const jsonHeader = {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
    ...csrfHeader,
  }

  $('#roleId').change(async (e) => {
    let roleId = $('#roleId').val()
    if (!roleId) return

    resetCheckBox()
    toggleBtnAndCheckbox(true)
    $('#permTable').attr('hidden', false)

    const url = `permission/${roleId}`
    try {
      const res = await fetch(url, {
        method: 'GET',
        headers: jsonHeader,
      })

      const { status, msg } = await res.json()
      if (status) {
        const { perms } = data['data']
        for (const perm of perms) {
          $(`#${perm}`).prop('checked', true)
        }
      } else {
        showMsg(msg, status)
      }
    } catch (err) {
      console.log(err)
    } finally {
      toggleBtnAndCheckbox(false)
    }
  })

  $('#permissionForm').submit(async (e) => {
    e.preventDefault()
    toggleBtnAndCheckbox(true)
    
    const formData = new FormData(e.currentTarget)
    const url = window.location.href
    
    try {
      const res = await fetch(url, {
        method: 'POST',
        headers: csrfHeader, // 注意:jsonHeader使うとformDataが取得できません
        body: formData
      })

      const { status, msg } = await res.json()
      showMsg(msg, status)
    } catch (err) {
      console.log(err)
    } finally {
      toggleBtnAndCheckbox(false)
    }
  })

  function resetCheckBox() {
    $('input:checkbox').prop('checked', false)
  }

  function toggleBtnAndCheckbox(status) {
    $('#saveBtn').prop('disabled', status)
    $('input:checkbox').prop('disabled', status)
  }

  function showMsg(msg, status) {
    let label = status ? "danger" : "success"
    let msgEl = `
          <div class="alert alert-${label} alert-dismissable" role="alert">
            <button type="button" class="close" data-dismiss="alert" aria-hidden="true" >
              &times;
            </button>
            ${msg}
          </div>
          `
    $(".message-area").append(msgEl).hide().slideDown(500, 0).fadeIn(1000, 0)
    setTimeout(() => {
      $(".alert")
        .fadeTo(500, 0)
        .slideUp(600, function () {
          $(this).remove()
        })
    }, 3000)
  }
})

前から結構権限管理について気になっていて、今回を機に、MVCを含めて、権限管理の実装をまとめました。LaravelのEloquent Relationship, Gateなどの便利な機能のおかげで、コアな部分はかなりシンプルに書くことができました。これをベースに、ビューとコントローラーの実装も簡単になります。やはりLaravelは素晴らしいフレームワークですね。

以上です!

Discussion