この記事は SmartHR Advent Calendar 2019 18日目の記事です。
こんにちは、こんにちは。SmartHRでエンジニアをしている疋田です。
この記事では、オブジェクトの属性取得をロールや条件で制御できるgemを作ったのでその紹介をします。
はじめに
突然ですが、ロールによる制御があるアプリケーションの開発って大変ですよね。
管理者なら参照可能でも、一般ユーザでは参照できない情報や、許可されていない操作みたいなものを制御するのは、アプリケーション開発の中でも気をつかう処理の一つだと思います。そういった処理は可能な限り責務を分割して、テスタブルにしておきたいところです。
Railsでは認可に対しては、Punditやcancancanなどのgemがあり、アクションやリソースに対してのアクセス制御をいい感じに書くことができます。
でも、リソースの持つ属性1つ1つに対して参照条件を管理できるようなgemを僕は知りませんでした。今回は、もしかしたらそんなものがあったら便利かもしれないと思い、とりあえず作ってみました。のお話です。
どんなgemを作ったか
scoped_attributes というgemをつくりました。
このgemを使って、オブジェクトをラップするクラスを作ると、そのオブジェクトの属性に対する、参照条件を簡単に追加することができます。また、オブジェクトの参照制御の責務をクラスとして分割できるので、テストも書きやすくなります。
APIレスポンスやViewにデータを渡す処理などで、管理者ならこのデータを返す、一般ユーザならこのデータを返すみたいな処理を書いている場合、このgemを使ってその取得処理を分割できるかもしれません。
モチベーション
以下のようなコードの、ロールごとに取得できる属性制御の部分を分割して、共通化したい、テストしやすくしたいというのが狙いです。(ちなみに、以下のコード含む本記事のコードはSmartHRには一切関係のない架空のコードです)
class Api::CrewsController < ApplicationController
def show
@crew = Crew.find(params[:id])
render json: if current_user.admin?
crew.as_json(only: %i(id name address evaluation))
elsif me?
crew.as_json(only: %i(id name address))
else
crew.as_json(only: %i(id name))
end
end
private
def me?
current_user.crew.id == @crew.id
end
end
特にSPAなどで作成していると、APIで取得した情報は見えてしまうリスクがあるので、レスポンスの情報などは気をつけたい部分だと思います。
(実際には、上記のようなコードは書かずに、すでにクラス分割がされているケースのが多いかもしれません)
使い方
ScopedAttributes
というモジュールをクラスに include し、アプリケーションで使うロールを roles
メソッドを使って定義します。
また、定義したロール名の suffix に ?
をつけた boolean を返却するメソッドを作成して、ロールの条件を作成します。
ロール定義の処理は親クラスとして定義しておくと便利だと思います。
class ApplicationScopedModel
include ScopedAttributes
roles :admin, :manager
def admin?
user.admin?
end
def manager?
user.manager?
end
end
なお、上で使っている user は initialize 時に引数で受け取るオブジェクトです。(利用する部分をみるとわかりやすいと思います)
次に今作成したクラスを継承した子クラスを作成し、 attribute
というクラスマクロを使って、このクラスで利用できる属性を追加します。
class ScopedCrew < ApplicationScopedModel
attribute :id
attribute :name
attribute :address, only: proc { me? || admin? }
attribute :evaluation, only: %i(admin)
private
def me?
id == user.crew_id
end
end
attribute
を定義するとラップしたオブジェクトのデータを返す、readerメソッドがインスタンスメソッドとして追加されます。
また、 attribute
にはその属性を参照することができる条件として only
オプションが定義されています。
only
オプションには rolesで定義したロール名
、 proc
、 symbol
のいずれかを指定することができ、それぞれ以下のような意味を持ちます。
-
rolesで定義したロール名
- 定義されているロールの条件が一つでも一致する場合、その属性を参照可能
-
only: %i(admin manager)
の場合にadmin? || manager?
がtrue
の場合は参照可能
-
- 定義されているロールの条件が一つでも一致する場合、その属性を参照可能
- proc オブジェクト
-
proc.call
の戻り値がtrue
の場合、その属性を参照可能
-
- メソッド名のsymbol
- メソッドの戻り値が
true
の場合、その属性を参照可能
- メソッドの戻り値が
次に、上記で作成したクラスを使う処理です。
# User#admin?, ScopedCrew#me? いずれも false の場合
user = User.create(role: :general)
crew = Crew.create(name: "hoge", address: "tokyo", evaluation: "SS")
scoped_crew = ScopedCrew.new(crew, user)
scoped_crew.name
# => "hoge"
# 許可されていない属性は取得できません
scoped_crew.address
# => nil
# 許可された属性のみ hash で取得できます
scoped_crew.attributes
# => {:id=>1, :name=>"hoge"}
# include_key を true にすると許可されていない属性の値が nil となって取得できます
scoped_crew.attributes(include_key: true)
# => {:id=>1, :name=>"hoge", :address=>nil, :evaluation=>nil}
# selectされた状態のインスタンスを取得できます
scoped_crew.to_model
# => #<Crew:xxx id: 1, name: "hoge">
# Crewクラスにも scoped メソッドが生えてそれを使うことで scope を限定することができます
# その場合 "Scoped#{self.class.model_name}" のクラス名にする必要があります
crew.scoped(user)
# => #<Crew:xxx id: 1, name: "hoge">
# User#admin? が true の場合
user = User.create(role: :admin)
crew = Crew.create(name: "hoge", address: "tokyo", evaluation: "SS")
scoped_crew = ScopedCrew.new(crew, user)
# 許可された属性が hash で取得できます
scoped_crew.attributes
# => {:id=>1, :name=>"hoge", :address=>"tokyo", :evaluation=>"SS"}
# ScopedCrew#me? が true の場合
user = User.create(role: :admin)
crew = Crew.create(name: "hoge", address: "tokyo", evaluation: "SS")
scoped_crew = ScopedCrew.new(crew, user)
# 許可された属性が hash で取得できます
scoped_crew.attributes
# => {:id=>1, :name=>"hoge", :address=>"tokyo"}
このように、onlyの条件によって取得できる属性を制御することができるようになりました。
今後やりたいこと
この gem は勢いで作っただけなので、まだ実用的レベルではないと思います。今後は以下のようなことをやりたいなーとふんわり思っています。
- scopedクラス同士でassociationを持てるようにしたい(以下コードのような感じで)
- オブジェクトに対して紐付いている
departments
をclass_name
でインスタンス化してあげればいけそうなので多分できそう
- オブジェクトに対して紐付いている
class ScopedCrew < ApplicationScopedModel
attribute :name
has_many :departments, class_name: "ScopedDepartment"
end
ScopedCrew.new(crew, curernt_user).attributes
# => {name: "hoge", departments: [{name: "foo"}]}
- 配列や
ActiveRecord::Relation
への対応 - 条件に使うオブジェクトの名前が
user
になってるけど、実はuser
だけじゃなくて他の条件もある気がしているので、context
的な名前にしてあげたほうがいいのかもの対応をしたい - テストちゃんと書きたい
おまけ
proc の binding ってどう変えるんだろう
以下のようなコードで、only の proc を実行するときに、binding
を変えたかったというお話です。
attribute :address, only: proc { me? || admin? }
この処理はgem側で素直に proc.call
を実行してしまうと、 binding
がクラス自身の環境になってしまっているので、クラスメソッドの me?
や admin?
を実行してしまい、NoMethodError
が発生します。今回のケースは、インスタンス側の binging
で proc.call
を実行してほしかったので proc
の binding
をなんとかして変えたかったんです。
でも、UnboundMethod
のように Proc
の binding
を変更する方法がわからず(できるのかもわからない)、同僚の @Kinoppyd にアドバイスをもらって、今回は、一度メソッドに変換してからそれを使って bind(self)
するというやり方をとりました。
unbound_method = generate_method(:only_call, &only)
unbound_method.bind(self).call
def generate_method(method_name, &block)
method = nil
Module.new do
define_method method_name, &block
method = instance_method method_name
remove_method method_name
end
method
end
もし、他にもいい感じの方法があったらぜひ教えて欲しいです。
追加: 社内でアドバイスもらったんですが instance_exec
とかでもいけそうですね。まだ未検証ではありますが。
最後に
scoped_attributesを使うと簡単に属性の参照制御をコントロールできるし、クラス分割もできてテストが書きやすくなるかもよということを紹介しました。
gem開発は楽しいし(メタプロ楽しい)、使えそうなら、もうちょっと育てようかなという気持ちで今はいます。
荒削りではありますが、作ったgemの紹介でした。
(実はこのgemは「アドベントカレンダー書きます!」と言ったはいいものの、書くネタが思いつかなすぎて、ネタを作るために開発されたgemというのは内緒です)