Ruby on Rails における権限Gem まとめ と GlobalState | Offers Tech Blog
こんにちは!🐯
プロダクト開発人材の副業転職プラットフォーム Offers を運営する株式会社 overflow のエンジニアの Taiga🐯 です。
overflow の新規サービスにおいて、権限関連の制御(Model ベースアクセス)をどう導入するか検討した結果、GlobalState を Model レイヤー用に導入することとなりました。
付随しますが、権限関連の Gem での現状の選択肢は、「CanCanCan」「Banken」「Pundit」の 3 つであり、左記に関しては紹介記事も多いので二番煎じ感も否めません。
ですが、今回は、GlobalStateと上記 Gem3 種について備忘録としてまとめてみます。少しでも参考になれば幸いです。
はじめに
権限管理はサービスの 1 つの要であり、情報を扱う私達にとっては重要なファクターです。
ビジネスロジックの分岐によりコードが肥大化していく問題や、情報の漏れを防ぐためにどの権限管理方法を導入する前に、しっかりとメリット・デメリットを理解しておくべきですよね![1]
cancancan
CanCanCan は Ability クラスに認可のロジックを集約させ、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
使い方
Pundit は Controller の各アクションで 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クラスには、ControllerのActionに紐づくメソッドを実装するので、Model:Policy:Controllerが1対1対1という縛りが生まれる - 特定のモデルを扱う
Controllerが複数存在し 1 つのModelに対し複数のPolicyを必要とする場合Policyモデルをモンキーパッチするなり、拡張するなり工夫を強いられる
Pundit は Model ベースで定義出来ますが、Model:Policy:Controller が1対1対1という暗黙的な縛りが生まれるため、結局エンジニアが Policy モデルを拡張することになる可能性が高い印象です。
個人開発レベルで使ったことはありますが、業務レベルだと権限が複雑化し継承先の Policy をカスタマイズすると複雑性が増しそうだなといった印象です。
banken
banken は、Pundit での 1対1対1の Model 縛りを避けるために、Controller 毎に Action に対する認可条件を定義する Gem です。
参考:日本語ドキュメント もあります。
使い方
ドキュメントの内容を転記しますが、
流れとしては、
-
ApplicationControllerにinclude Bankenする -
app/loyalties/application_loyalty.rbを作成する -
app/loyalties/配下に、対象のControllerのPolicyクラスを作成(例ではPosts) - アクションにて制御
まず大元で定義し、
# 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 単位でアクセスの制御が出来ていませんでした。
漏洩するリスクを減らしたいため、 下記のステップを踏ませるように変更しました。
-
set_address_policyメソッドで、「GlobalStateRegistry::RolePolicy.userがaddressにアクセスする権限があるか」をチェック -
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 も有効かと思います。
Discussion