概要
いわゆる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ともそれなりに理解できたと思います。その分、時間はかかってしまいましたが・・・
まあ、とは言えもっと良い設計はあると思うので、自分はこうしたよ、とか、こうしたら?みたいのあったらコメントいただければと思います。