🐯

Ruby on Rails における権限Gem まとめ と GlobalState | Offers Tech Blog

2022/10/03に公開

こんにちは!🐯
プロダクト開発人材の副業転職プラットフォーム Offers を運営する株式会社 overflow のエンジニアの Taiga🐯 です。

overflow の新規サービスにおいて、権限関連の制御(Model ベースアクセス)をどう導入するか検討した結果、GlobalState を Model レイヤー用に導入することとなりました。

付随しますが、権限関連の Gem での現状の選択肢は、「CanCanCan」「Banken」「Pundit」の 3 つであり、左記に関しては紹介記事も多いので二番煎じ感も否めません。
ですが、今回は、GlobalStateと上記 Gem3 種について備忘録としてまとめてみます。少しでも参考になれば幸いです。

はじめに

権限管理はサービスの 1 つの要であり、情報を扱う私達にとっては重要なファクターです。
ビジネスロジックの分岐によりコードが肥大化していく問題や、情報の漏れを防ぐためにどの権限管理方法を導入する前に、しっかりとメリット・デメリットを理解しておくべきですよね![1]

cancancan

CanCanCanAbility クラスに認可のロジックを集約させ、Controller ベースで制御をかけていきます。

使い方

class Ability
  include CanCan::Ability

  # Modelは適当です 他の初期値は省略
  def initialize(admin_user)
    if admin_user.admin?
      can :manage, :all
      # 全てのコントローラーで全てのアクション実行可能

    elsif admin_user.author?
      can :manage, Article
      cannot :destroy, Article
      # Article の destroy を 除く全てのアクションが実行できる

    elsif admin_user.sales?
      can :read, Fee
      # Feeの読み取り(index, show)だけができる
    end
  end
end

上記のように、各権限毎に app/models/ability.rb にてアクセス制御を行い、下記のように Controller にて定義します。
この場合の load_and_authorize_resource では、 @article = Article.find(params[:id]) が action 定義時に自動で読み込まれています。

class ArticlesController < ApplicationController
  load_and_authorize_resource

  def index
  end

  def show
  end

  def create
    @article.create
  end

  def edit
  end

  def update
    @article.update(article_params)
  end

  def destroy
    @article.destroy
  end

  private

  def article_params
    params.require(:article).permit(:body)
  end
end

メリット

  • Ability クラスを見れば権限一覧が理解しやすい
  • RailsWay かつ権限が平易な場合は管理しやすい

デメリット

  • RailsWay の 7 つの Resource 以外の action を追加した場合、処理が複雑化し肥大化する
  • Controller ベースのため Model 単位で制御は難しいため、権限管理が複雑な場合も肥大化しやすい
  • load_and_authorize_resource などといった独自 DSL が多く、使いこなしづらく、覚えるのも辛い

業務(現職ではありません)で使用したことがありますが、Ability クラスが数千行になり、メリットの可視性も享受できなくなりツラミを感じる回数が多かった印象にあります。

pundit

使い方

PunditController の各アクションで authorize リソースオブジェクトを呼ぶと対象のリソースに対して権限があるかどうかを確認してくれます。
その設定を app/policies 配下にある policy file で細かく定義できます。

class AppolicationPolicy
  attr_reader :user, :post

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

  def update?
    user.admin? || !post.published?
  end
end

使い方はシンプルで、ApplicationPolicy を継承して Policy を定義していくイメージです。

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? or not record.published?
  end
end

実際のアクションで、制御をかけます。

def update
  @post = Post.find(params[:id])
  authorize @post
  if @post.update(post_params)
    redirect_to @post
  else
    render :edit
  end
end

メリット

  • 継承し Policy を定義出来るため Ruby に慣れていると使いやすい
  • Pundit の内部実装は Rails のバージョンアップによる影響を受けにくい(モンキーパッチなど拡張していない)

デメリット

  • Model 毎に作成する Policy クラスには、ControllerAction に紐づくメソッドを実装するので、 Model:Policy:Controller が1対1対1という縛りが生まれる
  • 特定のモデルを扱う Controller が複数存在し 1 つの Model に対し複数の Policy を必要とする場合 Policy モデルをモンキーパッチするなり、拡張するなり工夫を強いられる

PunditModel ベースで定義出来ますが、Model:Policy:Controller が1対1対1という暗黙的な縛りが生まれるため、結局エンジニアが Policy モデルを拡張することになる可能性が高い印象です。

個人開発レベルで使ったことはありますが、業務レベルだと権限が複雑化し継承先の Policy をカスタマイズすると複雑性が増しそうだなといった印象です。

banken

banken は、Pundit での 1対1対1の Model 縛りを避けるために、Controller 毎に Action に対する認可条件を定義する Gem です。
参考:日本語ドキュメント もあります。

使い方

ドキュメントの内容を転記しますが、

流れとしては、

  1. ApplicationControllerinclude Banken する
  2. app/loyalties/application_loyalty.rb を作成する
  3. app/loyalties/ 配下に、対象の ControllerPolicy クラスを作成(例では Posts)
  4. アクションにて制御

まず大元で定義し、

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Banken
  protect_from_forgery
end
#app/loyalties/application_loyalty.rb
class ApplicationLoyalty
  attr_reader :user, :record

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

  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end
end

Loyalty クラスでも作成し定義

# app/loyalties/posts_loyalty.rb
class PostsLoyalty < ApplicationLoyalty
  def update?
    user.admin? || record.unpublished?
  end
end

authorize! @post にて、Pundit と似たように Controller(Pundit では Model で制御していましたね) から、Loyalty クラスでの制御に変わりました。

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  # 他の処理は省略
  def update
    authorize! @post

    if @post.update(post_params)
      redirect_to @post, notice: 'Post was successfully updated.'
    else
      render :edit
    end
  end
  # 他の処理は省略
end

メリット

  • Pundit のデメリットの1対1対1の制限を受けずに、Controller ベースで権限制御できる
  • 継承し Loyality を定義出来るため Ruby に慣れていると使いやすい
  • Banken の内部実装は Rails のバージョンアップによる影響を受けにくい(モンキーパッチなど拡張していない)

デメリット

  • 使用者が少ない
  • NameScope 機能がない

個人的所感としては、Controller 毎に定義できる点が魅力ですが、権限系は一度実装すると変更が容易ではなくなるので、メジャーな Pundit をうまく継承しつつ
使ったほうが良いのでは?と思っております。

GlobalState

GlobalState とはなにか

GlobalState は Ruby on Rails の CurrentAttributes を扱った代物です。

定義したい要素を Global な Scope で定義し、参照できるといったイメージです。

使い方

簡略化して説明します。

まず CurrentAttributes を使える用 module 等に定義します。

module GlobalStateRegistry
  class RolePolicy < ::ActiveSupport::CurrentAttributes
    attributes :address
    attributes :user

    # set -> with_isolated_registry
    alias_method :with_isolated_registry, :set

    def serialize
      self.attributes
    end
  end
end

そして、実際に policy を定義をします。
(今回はこのメソッドが User のインスタンスから叩かれる想定です)
(User モデルにて、include UserRolePolicyMethods します )


module UserRolePolicyMethods
  extend ActiveSupport::Concern
  def generate_role_policy
    role_policy = {
      address: false
      user: self,
    }
  end
end

すると、GlobalStateRegistry::RolePolicy が定義されている場合、
GlobalContextRegistry::RolePolicy.user は、スコープ外で user インスタンスへアクセス出来るようになります。

なぜ GlobalState を使ったか

弊社の新規サービスでは、センシティブな情報が複雑な条件の管理の元に実装されており、更に Serilalizer 毎に返していい情報を分岐で処理していました。
先程の例でいうと address がセンシティブな情報だった場合、 model 単位でアクセスの制御が出来ていませんでした。

漏洩するリスクを減らしたいため、 下記のステップを踏ませるように変更しました。

  1. set_address_policy メソッドで、「GlobalStateRegistry::RolePolicy.useraddress にアクセスする権限があるか」をチェック
  2. GlobalState を介していなければアクセスできない

結果として、「いかなるときでもそのカラムにアクセスされた場合には必ず GlobalState を介していなければデータを返さない」といった構成が完成しました。

# 一例です
def address
  if address_can_read_by_user?
    self.read_attribute(:address) || ""
  else
     ""
  end
end

def address_can_read_by_user?
  # Bool返す & イメージです 
  GlobalStateRegistry::RolePolicy.user.set_address_policy(self.id)
  GlobalStateRegistry::RolePolicy.address
end

メリット

・情報をモデル単位で制御できる安心感
・複雑な条件になっても、Policy を追加すればいい

デメリット

・スコープが限定されていないので、用法用量を守れないとコードが荒れる
・テストコードの難易度が上がる(GlobalState を定義しないとテストにならない)

まとめ

要件により権限権限のベタープラクティスは変わるかもしれません。しかし、自身で権限設定を追加出来るGlobalStateは用法用量[2]を守れば使い勝手のいい手法といった印象であるため(基本的には有害と言われていますが)今回導入しました。

本来なら RLS を使うと更に better かもしれませんが、通常の Gem に加えて更に制御を加えたい場合や、スコープを無視して制御せざるを得ない場合には、GlobalState も有効かと思います。

脚注
  1. 下記の説明は一部分の紹介で概要をサクッと理解する程度のレベルですので、詳細はドキュメントを見て頂けると幸いです ↩︎

  2. 基本的に、追加すべきではないという前提を持つ方が良いです。 ↩︎

Offers Tech Blog

Discussion