👑

権限周りの設計

2022/03/19に公開

以下の記事を投稿して1年が経ちました。ノウハウが溜まってきたので、現時点で僕が理解している範囲で共有したいと思います。バックエンド向けの記事です。
https://zenn.dev/dove/articles/bc6933dbb39509

ここで言うところの権限とは、誰がこの機能を使えるか決めることです。認可と意味は一緒なのかな?

権限パターン

以下のような4つの機能があったとき、

  • 取得
  • 作成
  • 更新
  • 削除

権限管理は以下のパターンがあります。

  • ユーザーに直接機能を紐付ける(例: ユーザーAは取得のみ可能、Bは作成更新のみ可能)。
  • 機能と権限識別記号を紐付ける(例: permissionADMINであれば全機能可能。USERであれば取得のみ可能など)
  • ユーザーの属性に応じて機能を紐付ける(例: ユーザーが北海道出身で25歳以下のとき、削除が可能)

ユーザーに直接機能を紐付ける

各ユーザーごとに細かく機能を割り当てたいときに便利です。細かく権限管理したくない場合は、今後扱う項目が増えていくと面倒です。

権限テーブル

id userId allow_get allow_create allow_edit allow_delete
1 1 true false false false
2 2 false true true false

コード例

function get () {
  if(!user.allow_get){
    throw new Error('権限エラー');
  }
  // 取得処理
}

機能と権限識別記号を紐付ける

みんなが一番使っているのではないでしょうか?実装の観点からもこれが一番作りやすいと思います。この中にも複数のバリエーションがあります。

  • 権限識別記号がisAdminなどのフラグ。
  • 権限識別記号がenum型(ADMIN, USER, SUPER_USER などの決まった文字列を受け付ける)。
  • 権限識別記号が数字でレベルを表す。レベル3以上はこの機能使えるみたいな。軍隊とかでつかわれてそう。

ユーザーテーブル

id permission
1 ADMIN
2 USER
2 SUPER_USER

コード例

function get () {
  if(user.permission !== 'ADMIN' || user.permission !== 'USER'){
    throw new Error('権限エラー');
  }
  // 作成処理
}

function create () {
  if(user.permission !== 'ADMIN'){
    throw new Error('権限エラー');
  }
  // 作成処理
}

ユーザーの属性に応じて機能を紐付ける

あまり見かけないですね。実装も複雑になりがちです。こちらは権限として統一てきに扱われるより、アプリコード内でビジネスロジックのルールとして、この場合は弾くみたいな例外処理で実現されることが多い気がします。

ユーザーテーブル

id age
1 23
2 42
2 56

コード例

function get () {
  if(user.age > 30){
    throw new Error('権限エラー');
  }
  // 作成処理
}

権限チェックをどの層で行うべきか

コントローラー層とサービス層を分けているアプリの場合、どちらで権限チェックを行うべきでしょうか?サービスの再利用性を考えるとコントローラー層でやったほうがよいです。コントローラー層で権限チェックを行えば、

  • サービス層に権限識別情報を渡さなくて良い。
  • 他のサービスからそのサービスを呼び出しやすくなる(権限チェックなしに呼びたい)

というメリットがあります。

複雑な権限はなるべくシンプルな仕様にできないか?

以下の仕様をイメージしてください。

ユーザーの悩みを解決するカウンセリングサービスがあるとします。ユーザーが悩みを投稿すると、ファシリテーター役の管理者と、数名の他のユーザーが参加し、投稿者の悩みを対話していきます。投稿者の悩みが解決した時点で、「お悩み解決」ボタンを押してもらい、お悩みが解決します。お悩みに対して、ロールが存在します。「投稿者」「ファシリテーター」「対話者」の3種類です。それぞれ以下の機能が利用できます。

投稿者 ファシリテーター 対話者
コメント投稿
評価スタンプ
お悩み解決ボタン

ロールとは別に一般ユーザーと管理者の2つの権限ユーザーが存在します。それぞれ以下の機能が利用できます。

一般ユーザー 管理者
お悩み投稿
対話者参加
ファシリテーター参加
アカウント停止
お悩み削除

ここで、運よくサービスがバズって、管理者だけではお悩み回答が追いつかなくなってきたとします。そこで、お悩み回答権限をサービス利用歴が1年以上のユーザーに開放することにしました。このユーザーのことをこれからスーパーユーザーと呼ぶことにします。それぞれ以下の機能が利用できます。

一般ユーザー スーパーユーザー 管理者
お悩み投稿
対話者参加
ファシリテーター参加
アカウント停止
お悩み削除

さて、ここで「評価スタンプ」機能の権限チェックを行いたい場合、どちらの仕様がよいでしょうか?

  • 権限が「スーパーユーザー」または「管理者」であるかつ、ロールが「ファシリテーター」であるユーザーのみ利用可能。
  • 単にロールが「ファシリテーター」であるユーザーのみ利用可能。

僕がおすすめするのは、できる限り権限チェックを複合条件にせずに、単純条件にすることです。つまり、ここでは後者のロールチェックのみでよいと思います。

仮に権限とロール両方を管理しなければならない場合、それぞれの数が増えれば増えるほど組み合わせ爆発が起きてしまいます。それらをすべてテストするのはかなり大変です。単純にロールだけでよいのなら、ロールの種類分テストすれば済みます。

権限チェックをユーザークラスのメソッドで行うよりも、各コントローラー層で権限識別記号をハードコーディングしたほうが作りやすい

僕は最初以下のような作り方をしてました。

例えば以下のようなユーザークラスがあって、そこに権限チェックメソッドを作成し、

User.ts
class User {
  private permission = "ADMIN";
  
  canGet() {
    if(this.permission === 'ADMIN'){
      return true;
    }
    
    return false;
  }
}

コントローラー層やサービス層で呼び出す。

コントローラー
class HogeController {
  get(
    req: Request,
    res: Response,
    next: NextFunction,
  ): void {
    // 認証後のユーザーを取得
    const currentUser = req.user;
 
    // 権限チェック
    if(!currentUser.canGet()){
      // 権限チェックに通らなければ403ステータスコード返す。
      res.sendStatus(403);
      return;
    }
    
    // 権限チェックに通れば200ステータスコード返す。
    res.sendStatus(200);
  }
}

しかし、以下のやり方のほうが結局作りやすいなと思いました。以下のやり方では、どの権限名がこの機能を利用できるかの情報はユーザークラスが持っていません。

permissionGuard.ts
type PermissionName = 'ADMIN' | 'USER' | 'SUPER_USER';

export const permissionGuard = (...permissionNames: PermissionName[]) => {
  return (user: User) => {
    // 与えられたパーミッションの中にユーザーのパーミッションが含まれなければfalseを返す。
    if(!permissionNames.include(user.permission)) return false;
    
    return true;
  };
}
コントローラー
import { permissionGuard } from './permissionGuard';

class HogeController {
  get(
    req: Request,
    res: Response,
    next: NextFunction,
  ): void {
    // 認証後のユーザーを取得
    const currentUser = req.user;
 
    // 権限チェック
    if(!permissionGuard('ADMIN')(currentUser)){
      // 権限チェックに通らなければ403ステータスコード返す。
      res.sendStatus(403);
      return;
    }
    
    // 権限チェックに通れば200ステータスコード返す。
    res.sendStatus(200);
  }
}

フレームワークによっては、ミドルウェアで実現できると思います。NestJSではここらへんをデコレーターを使ってうまく実現しています。

https://docs.nestjs.com/guards

@UseGuards(JwtGuard, RolesGuard) // ガード機能
class HogeController {
  @Get('/')
  @Roles('ADMIN') // ADMINのみ利用可能
  get(
    @Req() req: Request,
    @Res() res: Response,
    @Next() next: NextFunction,
  ): void {
    // この時点で権限チェックに通っているはず。200ステータスコードを返す。
    res.sendStatus(200);
  }
}

Discussion