Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

権限管理の苦い思い出を新規サービスで昇華した話

f:id:HaRuKaOden:20220402105031p:plain

どうも、リンクアンドモチベーションの伊藤です。 モチベーションクラウドシリーズのエンジニアをしています。 新規サービス開発を行っている際に改めて「権限」をどう扱うべきか悩んだため、ここでまとめていきたいと思います。

結論から述べると、ロールベースアクセス制御(RBAC)を中心に、apiごとにpolicyを定義するようにしました。

前提

設計を始める前に考えていたことはこんなことでした。

  • ユーザーにわかりやすい機能制限にしたい
  • 開発者にもどうなっているかわかりやすくしたい
  • 新規開発なので、拡張性も多少は意識したいが、工数もかけたくない

特に前に担当していたサービスでこの権限管理周りで苦労した経験があるので、開発者体験もできるだけ落とさないというのも重要視しようと思っていました。

どう権限管理を設計していくか

google先生に「権限管理 設計」や「アクセス制限 設計」などで検索するとものすごい数のサイトで解説されていますが、結局のところケースバイケースという結論が多いようでした。

そこでまずロールベースアクセス制限(RBAC)と属性ベースアクセス制限(ABAC)のようなオーソドックスな二つの制限の方法を基準に考えていきました。 それぞれの詳細は解説記事が多数ネットにもあるので参照ください。

サービスとして、ロールベースも属性ベースもありえそうというところでしたが、新規ということもあるので、工数やわかりやすさを重視し、ロールベースな制限を中心に考えることにしました。
属性ベースな制限ももちろん存在はしますが、個々の機能の要件であり、全体を通した制限の設計はニーズが増えた時に検討することにしました。
ロールベースアクセス制限のデメリットとしてロールの大量生成が挙げられますが*1、弊サービスではユーザーにロールを増やすような状況はまだないため、問題ないとしました。

ここから先はサービス特性や制限の要件次第なので、次サービスの特徴をまとめながら整理することにした 特にこちら↓の記事を参考に考えていきました。

applis.io

要件や条件の整理

具体的な権限に関する要件は 「制限をかける粒度」× 「制限するロール」 に分解して整理しました

※ 内容は本屋のサイトに置き換えています

制限をかける粒度

粒度の大きさ 制限単位 具体的なイメージ
ストーリー 本を検索できること(本の一覧から検索を行い検索結果を閲覧できること)
画面 本の検索画面が使えること
api(機能) クエリに応じた本の一覧を返すapi、本の詳細を更新するapi
小~中 リソース(CRUD) 本、在庫数

ロール

名前 役割
一般ユーザー ログインをして弊サービスを利用する、情報の閲覧を行う
管理者 本の情報登録や追加を行う
著者 自分の本の情報更新を行う
弊社社員 管理者の登録や削除を行う、本の管理も行う

これらの情報を整理すると以下のようになりました

ストーリー 画面 機能(api) リソース 実行可能ロール
本を検索できる 本の一覧画面 本の一覧を取得する 一般ユーザー
管理者
著者
弊社社員
本の検索画面 本の検索結果を取得する 一般ユーザー
管理者
著者
弊社社員
本の詳細画面 本の詳細を取得する 一般ユーザー
管理者
著者
弊社社員
著者の詳細を取得する 著者 一般ユーザー
管理者
著者
弊社社員
本の詳細を更新する 管理者
著者
弊社社員
著者の詳細を更新する 著者 著者
本を管理できる 本の管理画面 本の一覧と在庫を取得する 管理者
著者
弊社社員
本の在庫を更新する 管理者
弊社社員
本の登録情報を追加する 管理者
著者
弊社社員
本の登録情報を削除する 管理者
弊社社員
ユーザーを管理できる ユーザー管理画面 ユーザーの一覧を取得する ユーザー 管理者
弊社社員
ユーザーの検索結果を取得する ユーザー 管理者
弊社社員
ユーザーの詳細画面 ユーザーの詳細を取得する ユーザー 管理者
弊社社員
ユーザーの詳細を更新する ユーザー 管理者
弊社社員
ユーザーを追加する ユーザー 弊社社員
ユーザーを削除する ユーザー 弊社社員
自分のプロフィールを見る プロフィール画面 ユーザー情報を取得する ユーザー 一般ユーザー
管理者
著者
弊社社員

この表を眺めていくと

  • ストーリー単位にしようにも複数のロールが複数の目的で利用しそう
  • 画面によっては利用ユーザーのロールに応じて使える機能が制限されそう
  • 機能ごとで実行可能ロールが分かれそう
  • リソースはユーザーの属性に応じて制限されそうだが、ロールに応じて制限はされなさそう
    (おいおい対応していきたいかも?)

ということが見えてきました。

なのでこのタイミングでは機能 (api) 単位で制限をかけるような設計がベターだねということになりました。

とはいえ機能に制限がかかったユーザーに対して制限された機能への動線は隠してあげないとUXが悪くなりそうだったのでそこは画面側でも制御を入れてあげようということになりました。
(例えば一般ユーザーが本の詳細画面にいる時「本の詳細を更新する」は実行できないので、更新ボタンは隠してあげるなど)

権限管理の実装

他サービスで権限管理に関してしんどいと思っていた大きな理由は

  • 権限による機能制限は新規api実装時に強制されていなかった (実装者の努力によってなんとかなってた)
  • apiの処理の中に機能制限のロジックが入り込み、そのapiがどんな制限がかかっているのかわかりにくかった
  • ロールによる機能制限のロジックが分散し、理解に時間がかかった

でした。(後からどんどん改善されました)
なので今回はできるだけ「強制力」「分離」「集約」を意識した実装にしていきたいというのが設計時の思いとしてありました。

この制限を自前でやっても良いですが、良いライブラリがないかなというところで探してみると、railsでは以下の二つが人気なようでした。
この二つのgemに関してはこちら(PunditとCanCanCanの比較 | 働くひとと組織の健康を創る iCARE)をはじめとして多くの記事があったのでそれらを漁りながら実際に使ってみた結果、「pundit」を採用することにしました。

github.com github.com

punditもcancancanも「強制力」「分離」「集約」を実現できましたが、punditを採用した理由は

  • pure rubyでかける
    • ⇄ cancancanはDSLで混乱がありそうだった
  • 制限クラスがシンプルに書ける (コントローラ単位で分かれるので見にくいという意見もあります)
    • ⇄ cancancanはabilityというクラスに全てロールの全てのリソースへの制限をまとめる必要があり制限ロジックが肥大化しそうだった
  • apiごとの制限」もゆくゆくやりたい「リソースごとの制限」も簡単に実現できそう
    • ⇄ cancancanでapiごとの制限はちょっと面倒そうだった(違ってたらごめんなさい)

でした。

最終的にはpunditを使って以下のような実装することにしました。

  • 利用ユーザーはロールを通して権限(Permission)を持つように
  • policyファイルを作成して、app/contorllersから「分離」
  • app/policies以下にコントローラごとの制限を「集約」
app/policies
├── api
│   ├── authors_policy.rb
│   ├── books_policy.rb
│   └── users_policy.rb
└── application_policy.rb
# application_policy.rb
class ApplicationPolicy
  attr_reader :user

  def initialize(user, _record)
    @user = user
  end

  def current_permissions
    return @current_permissions if defined?(@current_permissions)
    @current_permissions ||= user.permissions
  end
end

# books_policy.rb
class Api::BooksPolicy < ApplicationPolicy
  def index?
    Permission::BOOK_VIEWER.in? current_permissions
  end
end

また、ApplicationControllerに以下のような認可用のmoduleを注入して、policyによる認可を「強制」させています。

module Authorizable
  extend ActiveSupport::Concern

  include Pundit::Authorization

  included do
    # 全てのコントローラのアクションにおいてpolicyによる認可を強制させました
    before_action :authorize_by_policy
  end

  private
    # include先のcontrollerで定義しています
    # Permissionsを返すように設計されたuserを渡せるようにしています
    def authorizable_user
      raise NotImplementedError
    end

    # pundit_userがApplicationPolicy.newの第一引数になります
    # defaultはcurrent_userを勝手に探してくれますがカスタマイズしたいので定義しました
    def pundit_user
      authorizable_user
    end

    def authorize_by_policy
      authorize policy_path
    end

    # symbolのarrayを渡すと、指定したpathのpolicyファイルを元に制限してくれます
    def policy_path
      [:api, *controller_path.split('/').map(&:to_sym)]
    end
end

今後に関して

今後の機能開発をシンプルかつスムーズに進めるためにも以下は進めていきたいなと思っています。

  • ユーザーの属性に応じたリソースのスコープ制限の分離
  • ロール追加時の工数削減