2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CanCanCanの複雑なアクセス制限をJavascriptと共有する

Last updated at Posted at 2021-02-10

概要

いわゆるJavascriptを駆使したシングルページアプリケーション(SPA)で、かなり複雑なアクセス制限の要望があり悪戦苦闘し、なんとなく良い感じにまとまったので記事にします。

この記事では以下のような情報がわかります。

  • モデルの属性(テーブルのカラム)ベースでのアクセス制限の方法
  • 条件によるアクセス制限の方法。例えば過去のデータは変更できないなど。
  • cancancanでサーバー側で設定したアクセス制限フロントエンドのcasl/abilityに共有する方法とその制限
  • フロントエンド側で複雑なアクセス制限を実装するときの問題点と工夫。
  • サーバーサイド側で複雑なアクセス制限を実装するときの問題点と工夫。

ざっくりなシステムの仕様ですが、

  • サーバーサイドはrails6 + cancancan(3.2.1)
  • フロントエンドはreact16 + react-redux + casl/ability(5.2.2)

アクセスコントロールはサーバーサイドは定番ですがcancancanを採用、Javascript側はcaslを使います。この二つはほぼ仕様が同じなのでデータの共有がしやすいです。

アプリケーション情報はいらないかもしれませんが、イメージしやすいと思うので簡単に紹介しておくとスタッフベース(席の予約ではなく、スタッフに予約を入れるタイプ。美容室やマッサージ店などをイメージすると良いかも。)の予約システムでユーザーロールは下記のようなものがあります。

  • 管理者 - 何でもできる
  • 編集者
    • ステータスが会計済みになってる予約の編集はできない。
    • 過去に新しい予約の追加はできない。
  • スタッフ
    • 編集者の制限に加え自分の予約以外は編集できない。
    • さらに、予約の中で変更できるものと変更できないものがある。つまり、モデルベースではなく、属性ベースでのコントロールが必要。

厳密にはもっと細かいですが・・・

  • モデルのCRUDだけでなく属性(カラム)の変更の制限がある。
  • 日付やモデルの状態など条件による制限がある。

ということが伝わればOKです。

サーバーサイドだけで弾けば、確かに目的は果たせるのですが、SPAなのでサーバーにPOSTするまでに色々操作ができるため、さんさん変更した後に「権限がないので編集できませんでした」というのはあまりに使い勝手が悪いため、Javascript側にもサーバーサイドのCanCanCanの設定を共有させ、変更しようとしたタイミングで変更できないのが分かるようにしました。

Abilityの設定

属性による制限、条件付き制限をどうやって表現するかですがcancancan/caslともサポートされています(cancancanは少なくとも2系は属性はサポートしてなかったと思います。バージョンあげました)。

# app/models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    # 属性の変更制限。Eventのdate attributeは変更できません、ということ。
    # ただし、
    # authorize!(:update, event)
    # で勝手に`event.date_changed?`を見てくれるわけではない(actionをmanageなどにしても同じ)。
    # authorize!(:change, event, :event_type_id) if event.event_type_id_changed?
    # と自分で変更をチェックしないとダメ。`event, :event_type_id`が合わせてsubjectのようなイメージ。
    cannot(:change, Event, :date)

    # 条件付き制限
    # event = Event.find(params[:id])
    # authorize!(:update, event)
    # のように渡すと`event.past?`が`true`の時例外が飛びます。`false`だとスルーされます。
    cannot(:update, Event, past?: true)

    # 属性の変更制限+条件付き制限もできる
    cannot(:change, Event, :date, owner?: false)
  end
end

JavascriptへのACLの渡し方

cancancanで設定した権限設定をまるっとcasl/abilityに渡します。

# app/models/ability.rb
class Ability
  include CanCan::Ability

  def initialize(user)
    # ここで権限設定
  end

  def to_list
    rules.map do |rule|
      object = { action: rule.actions, subject: rule.subjects.map {|s| s.is_a?(Symbol) ? s : s.name }}
      # カラム(attribute)
      object[:fields] = rule.attributes if rule.attributes.present?
      # can/cannotに渡した条件
      # 2021/03/10編集。配列に対応した。
      if rule.conditions.present?
        object[:conditions] = rule.conditions.each_with_object({}) do |(key, value), hash|
          next hash[key] = { '$in': value } if value.is_a? Array

          hash[key] = value
        end
      end
      # invertedはcanの時はfalse、cannotの時はtrueが来ます。
      object[:inverted] = true unless rule.base_behavior
      object
    end
  end
end

こちらのサイトを参考にさせていただきました。ただし、最新バージョンに合わせて少し変えてあるのと、カラムによる制限のためにfieldsを出力してます。

コントローラーやviewではcurrent_user.ability.to_listでアクセス可能なので、これをJSONにしてJSに渡せば利用可能です。

2021/03/10編集

CanCanCanでは条件を配列で渡すとinclude?でチェックします。

cannot :manage, Event, visit_status: ['booking', 'paid']

こうしておくとvisit_status attributeが'booking'か'paid'どちらでもmanageできなくなります。このままcaslに渡しても動きませんので、MongoDB query languageに変換しました。

制限

ご察しの通り、CANCANCANで条件にActiveRecordRelationやブロックを渡す奴はJSに渡せません。下記のような奴ですね。

# こっちは問い合わせが走り、全てのレコードが渡される・・・
cannot :read, Event, Event.where(secret: false)

# こっちは無視される
cannot :read, Event do |record|
  record.created_at < Date.today
end

ということはJS側に式は渡せないので、JS側で==で判断できものしか条件に使えなということになります。今回は全てbooleanにしました。

余談ですがcaslではMongoDB query languageを使って条件を記述できます。cancancanがそれに対応したらもう少し複雑な条件のやりとりもできるかもしれないですね。あるいは現状でもto_listメソッドを工夫すれば・・・流石にブロックを自動で変換するのは無理かな?

フロントエンド

問題点

JSONで受け取ったデータをそのままabilityのupdateに渡せはACLの設定は完了します。一度updateしたACLをどこでも使えるようにnewしたものをexportするjsを作っておくと便利です。シングルトンみたいなものですね。

// classes/Ability.js
import { Ability } from '@casl/ability'

export const ability = new Ability()
// reduxのactionsです。
// actions/index.js

import { ability } from '../classes/Ability'
//省略

export function loadTimelines(){
  //省略
  return ajax.request().then(data => {
      if(data.ability) ability.update(data.ability)
  //省略
}

こんな感じでajax読み込み時にupdateに渡してやればOKです。ただ、条件付きのアクセス制限を使う時が少し問題です。条件はサーバーサイドのcancancan同様、subjectに渡されたオブジェクトの値を参照します。しかも、クラスの名前が設定したsubjectと一致してないとダメです。

const event = new Event({'past?': true})
ability.cannot('manage', event)

こんな感じでサーバーサイド同様Eventという名前のclassが必要になります。JS側でrailsと同じようにテーブルごとにclassを作っていれば特に問題ないですが、フロントでは永続化しないので、テーブルと1対1のクラスを作ってるケースって少なくないですか?少なくとも私は一度もありません。

解決案

そこで動的にクラスを生成するようにしてみました。まずcaslがどうやってクラス名を特定しているかも確認するconstructor.nameからsubjectを特定してることがわかります。なのでconstructor.nameだけ設定してやれば動きそうです。

// reduxのactionsです。
// actions/index.js
// ぶっちゃけ一番下の`export function subject(...)`以外はこのファイルに置くの微妙ですけどとりあえずここでw

// 動的に生成したクラスのキャッシュ
const subjectClasses = {}

// 親クラス
class BaseSubject
{
  constructor(state, attributes = {}){
    // ここで、他に必要な条件をstateから組み立てる。
    // attributesはstateの値を強制的に上書きする時に使っています。

    // 例えばpast?はこんな感じ
    this['past?'] = attributes.hasOwnProperty('past?')
                  ? attributes['past?']
                  : differenceInCalendarDays(state.app.date, new Date()) < 0
  }
}

// 動的にクラスを生成する
function getSubjectClass(name){
  if(subjectClasses[name]){
    return subjectClasses[name]
  }

  subjectClasses[name] = class extends BaseSubject{}
  // constructor.nameの設定
  Object.defineProperty(subjectClasses[name], 'name', {value: name})
  return subjectClasses[name]
}

// container側で実際に使うメソッド
// 現在の条件をセットされたインスタンスを返す。
export function subject(subject_name, attributes = {}){
  return (dispatch, getState) => {
    const SubjectClass = getSubjectClass(subject_name)
    return new SubjectClass(getState(), attributes)
  }
}

使う時はこんな感じ(_ifこれです)。

<button
  {..._if(ability.can('update', this.props.subject('Event')), {
    onClick: e => this.props.changeDate()
  }).else({
    onClick: e => this.props.showAclError(e), className: 'bu-deny'
  }).any({
    className: v => classNames(v, 'btn btn-default btn-sm')
  }).yield()}
>
  <FontAwesomeIcon icon={faArrowsAltV} />
</button>

サーバーサイド

問題点

フロントエンドは操作するボタンやSelectタグにIF文を仕掛けていきます。作業量は多いですが、それほど悩まずにできました。

しかしサーバーサイドはチェックするタイミングが難しいです。例えば、最初の条件にあった「ステータスが会計済みになってる予約の編集はできない」ですが、ステータスを会計済みに変更することはできないとダメなので、保存直前ではなく、読み込んだ時点のステータスをチェックします。逆に「過去に新しい予約の追加はできない」は例えば、未来に予約を作って、過去に移動した時も弾かないとダメなので、属性更新後にチェックしないとダメです。

また、モデルは複数あって、それぞれ複雑な親子関係があります。例えば、予約はスタッフごとにEventモデルで表現し、複数のスタッフが一人のお客様を接客することを想定しているので、お客様の来店はVisitモデルで表現しています。

class Event < ApplicationRecord
  # スタッフ
  belongs_to :user

  # イベントは予約以外にも、休憩や掃除など複数の種類がある。
  belongs_to :event_type

  # 来店。予約以外にも種類があるのでoptional
  belongs_to :visit, optional: true
end

class Visit < ApplicationRecord
  has_many :events

  # お客様
  belongs_to :guest

  enum status: {
    booking: 1,         # 予約中
    came_shop: 2,       # 来店中
    # 省略
    checked: 7          # 会計済み
  }
end

Visitでpast?をチェックするシチュエーションがあったとしたらVisitにpast?メソッドを作るのですが・・・

class Visit < ApplicationRecord
  # 省略

  def past?
    events.any? {|ev| ev.date.past? }
  end
end

とやりたいところですが、まだEventが保存、あるいは更新されてないので使えません。先に保存すればわかりますが、保存する順番に影響されるのも後々の変更が怖いですし、関連モデルは他にもたくさんあるので、それぞれの順番を管理するのは大変です。

解決案

問題になっている条件は、一回の保存のリクエストの中で、EventもVisitもその他全てのオブジェクトが同じ条件を使い回すもの、共有の条件が問題になっています。

そこで、条件を表現したクラスをモデルに持たせて、それにdelegateさせます。条件のセットは、値が判明した時にどんどんセットしちゃいます。

# app/models/concerns/ability_subject.rb

# 条件チェックに必要なattributeをモデルに生成するmodule
module AbilitySubject
  extend ActiveSupport::Concern

  included do
    attr_accessor :ability_condition
    delegate :owner?, :past?, :checked_visit?, to: :ability_condition
  end
end
# app/models/ability_condition.rb

# 実際の条件の値を持っているクラス。モデルは条件判断用属性値はこいつにdelegateする。
class AbilityCondition
  def initialize(attributes)
    @attributes = {}
    bind(attributes)
  end

  def bind(attributes)
    attributes.each do |key, value|
      @attributes[key] = value
      # うっかり値が確定する前に読んだらエラーになるように、値をセットした時に初めてメソッドを作る。
      AbilityCondition.define_method(key) { @attributes[key] } unless AbilityCondition.method_defined?(key)
    end
  end
end
class Event < ApplicationRecord
  include AbilitySubject
  #省略
end

class Visit < ApplicationRecord
  include AbilitySubject
  #省略
end

コントローラではこんな流れになります。(本当は予約が重複しないように色々やってます。嵐コンサート事件のようなことは起きませんのでご安心を)

# app/controllers/ajax/books_controller.rb

def save
  event = Event.find_or_initialize_by(id: params[:id])
  event.attributes = {
    user_id: params[:user_id],
    date: params['date']
  }
  
  # AbilityConditionを生成。これは関連レコードで共有されます。
  # この二つはリクエストパラメーターで判別するのでここでセット。
  event.ability_condition = AbilityCondition.new(
    past?: event.date.past?,
    owner?: event.user_id == current_user.id
  )

  # 省略

  visit = Visit.find_or_initialize_by(id: visit_params[:id])
  # visitを読み込み会計済みかどうかが判明したのでセット
  event.ability_condition.bind(checked_visit?: visit.checked?)
  # visitの条件判定用にability_conditionを共有
  visit.ability_condition = event.ability_condition

  # 省略

  authorize!(visit.new_record? ? :create : :update, visit) if visit.changed?
  authorize!(:change, visit, :status) if visit.status_changed?
  authorize!(:change, visit, :guest_id) if visit.guest_id_changed?
  authorize!(:change, visit, :seat_id) if visit.seat_id_changed?
  visit.save!

  event.visit_id = visit.id

  # 省略
 
  authorize!(event.new_record? ? :create : :update, event) if event.changed?
  authorize!(:change, event, :event_type_id) if event.event_type_id_changed?
  authorize!(:change, event, :time) if event.start_date_changed? || event.end_date_changed?
  event.save!

ポイント

cancancanとcaslはかなり互換性が高く使い方も似ているのですが、フロントエンドとサーバーサイドで処理の流れが全く違うので悩みました。

あと、両方ともかなり自由度が高くてどうにでもできちゃうんですよね。例えば、最初属性による制限ができるのを知らなかった時、subjectをEvent.dateなどどしてやれば、ほぼ同じようなことができるんですよね。条件による制限も、条件をsubjectにすれば似たようなこともできます。ただ、Eventはdate.past?を見るけど、Visitは見ないなどはできないので、ちゃんと用意されてる機能を使った方が良いと思います。

また、authorize!ability.canを正しい場所に挿入していく作業はかなり大変ですが、一度しっかり実装したら、各ユーザーの権限が変わってもmodel/ability.rbを触るだけで済むというのは利点だと思います。実際cancancan/caslがなかったらこんな細かいアクセス制限を実装するのは大変だったでしょう。

最後に、結構ニッチな案件なので、この情報、必要な人も少ないと思いますが、こういう複雑なものを設計するとき、こうやってドキュメント化するのはお勧めです。後々のドキュメント替りというのもあるけど、それよりも、ドキュメントを書くと設計の問題点が浮き彫りになり、より良い設計へとたどり着けるからです。実際私も今回この記事を書きながら3回くらい方向転換し、記事も書き直しました。また、公開情報であるためにライブラリのドキュメントをしっかり確認したり、ソースを追ってみたり、cancancan/caslともそれなりに理解できたと思います。その分、時間はかかってしまいましたが・・・

まあ、とは言えもっと良い設計はあると思うので、自分はこうしたよ、とか、こうしたら?みたいのあったらコメントいただければと思います。

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?