LoginSignup
10
3

More than 3 years have passed since last update.

オブジェクトの属性取得をロールや条件で制御するgemを作った

Last updated at Posted at 2019-12-17

この記事は SmartHR Advent Calendar 2019 18日目の記事です。

こんにちは、こんにちは。SmartHRでエンジニアをしている疋田です。
この記事では、オブジェクトの属性取得をロールや条件で制御できるgemを作ったのでその紹介をします。

はじめに

突然ですが、ロールによる制御があるアプリケーションの開発って大変ですよね。

管理者なら参照可能でも、一般ユーザでは参照できない情報や、許可されていない操作みたいなものを制御するのは、アプリケーション開発の中でも気をつかう処理の一つだと思います。そういった処理は可能な限り責務を分割して、テスタブルにしておきたいところです。

Railsでは認可に対しては、Punditcancancanなどの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で定義したロール名procsymbol のいずれかを指定することができ、それぞれ以下のような意味を持ちます。

  • 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を持てるようにしたい(以下コードのような感じで)
    • オブジェクトに対して紐付いている departmentsclass_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 ってどう変えるんだろう :thinking:

以下のようなコードで、only の proc を実行するときに、bindingを変えたかったというお話です。

attribute :address, only: proc { me? || admin? }

この処理はgem側で素直に proc.call を実行してしまうと、 binding がクラス自身の環境になってしまっているので、クラスメソッドの me?admin? を実行してしまい、NoMethodError が発生します。今回のケースは、インスタンス側の bingingproc.call を実行してほしかったので procbinding をなんとかして変えたかったんです。

でも、UnboundMethod のように Procbinding を変更する方法がわからず(できるのかもわからない)、同僚の @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というのは内緒です)

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3