【Rails】ActiveAdmin＋CanCanCanで権限毎に色々したい

# 背景

ActiveAdminを、運用上のマスター管理だけではなく、編集者や寄稿者など権限設定して色々できるようにしたい。
ActiveAdminにはデフォルトでCanCanCanのアダプターが搭載されていますが、ちょっと癖があるのでうまく使えるようになりたい。

# やりたいこと

* CanCanCanで権限管理
* Indexページでは自分に割り当てられたレコードのみ一覧可能
* Showと更新系ページも自分に割り当てられたもののみアクセス可能
* 他人のページは絶対に見れない
* ActiveAdmin外でアクセス制限したいときにも同じAbilityで対応したい

ユーザー権限は例えばこんな感じ

* AdminUser（管理者）は全てのテーブルをCRUD可能
* OwnerUser（オーナー）は自分の記事のみCRUD可能
* EditorUser（編集者）はオーナーにぶら下がり、オーナーの記事のみCRUD可能
* ProofreaderUser（校閲係）も同様だが、編集のみ可能（RU）

# 準備

## ActiveAdminでCanCanCanを使う

```sh:Gemfile
# Admin Page
gem 'activeadmin', github: 'activeadmin'

# Role
gem 'cancancan', '~> 1.10'
```

```rb:config/initialize/active_admin.rb
  config.authorization_adapter = ActiveAdmin::CanCanAdapter
  config.on_unauthorized_access = :access_denied
```

```rb:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base

  # 権限がなくてアクセスできないページはダッシュボードへ飛ばしてメッセージを表示
  # 認証が切れてアクセスできない場合はログイン画面を表示
  def access_denied(exception)
    if current_user
      redirect_to admin_root_path, alert: I18n.t('active_admin.access_denied.message')
    else
      redirect_to new_user_session_path, alert: exception.message
    end
  end

  # ログアウトしたらログイン画面へ戻る
  def after_sign_out_path_for(resource)
    new_user_session_path
  end
end
```

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

  def initialize(user)
    @user = user

    if @user.present?
      [:super_admin,:admin,:owner,:editor].each do |role|
        # ユーザーに割り当てられたロール毎に権限を付与
        # ※本当は複数ロールを割り当てられている状態も扱いたいが、今回は１ユーザー１ロールの前提
        send(role.to_s) if user.has_role? role
      end
      # ログインしたユーザーは全てダッシュボードのアクセスが可能
      can :read, ActiveAdmin::Page, :name => "Dashboard"
    end
  end

  # 記事の校閲のみ行うアルバイト
  def proofreader
  end

  # オーナーから記事作成を委託されたライター
  def editor
  end

  # 記事の所有者
  def owner
  end

  # 運用上の管理人
  def admin
  end

  # システム的なマスターの管理やメンテナンスを行う
  def super_admin
  end
end
```

# CanCanCan

## CanCanCanの基礎

### can/cannot

* canはANDでマージされる
* cannotは上書きされる ***※順番が重要***

### Alias

* デフォルトのエイリアス

```rb
alias_action :index , :show, :to => :read
alias_action :create, :new , :to => :create
alias_action :update, :edit, :to => :update
alias_action :create, :read, :update, :destroy, :to => :manage
```

### 書式

```rb:ability.rb
# 基本は
# can/cannot action, model, condition, block

# actionとmodelは配列にもできる
can [:index,:show], [Article,User]

# conditionは絞り込み条件
can :read, Article, category: :news

# リレーションも指定できる
can :read, Article, owner: { id: 3 }

# Scopeでも指定できる
can :read, Article, Article.open

# blockで権限チェックをできる
can :show, Article do |subject, extra_args|
  subject.assigned_to? @user
end

# conditionとblockは一緒に指定できるが、その場合はHashの形は使用できない
#can :show, Article, category: :new do |subject, extra_args|
#  subject.assigned_to? @user
#end

# Scopeなら併用できる
# ※ただし、ActiveAdminとは相性が悪いので使えない
#can :show, Article, Article.open do |subject, extra_args|
#  subject.assigned_to? @user
#end
```

# 【本題】ActiveAdmin＋CanCanCan

## Indexページは自動的に絞り込まれる

適切に権限設定していれば、indexに表示されるレコードは自動的に絞り込まれます。
例えば、オーナーでログインしたとき、オーナー自身の記事しかindexに表示させたくない場合は、次のようにします。

```rb:app/models/article.rb
class Article < ActiveRecord::Base
  belongs_to :owner_user, class_name: :User, foreign_key: :owner_user_id
end
```

```rb:app/models/ability.rb
...

  def owner
    can :read, Article, owner_user: @user
  end

  def admin
    can :read, Article
  end
```

このようになっていると、管理者でログインすると全てのレコードが表示され、オーナーでログインすると自分の記事だけに絞り込んで表示されます。

内部的には、Indexページを表示するときにこのようになっているみたいです。

```rb:lib/active_admin/cancan_adapter.rb 
  def scope_collection(collection, action = ActiveAdmin::Auth::READ)
    collection.accessible_by(cancan_ability, action)
  end
```

`accessible_by`に`action= :read`が渡ることで上の条件が追加されて`collection`から更に絞り込まれています。

## 更新系は工夫が必要

### とりあえず、

上を踏まえてこうしてみました。

```rb:app/models/ability.rb
...

  def owner
    can :manage, Article, owner_user: @user
  end

  def admin
    can :manage, Article
  end
```

`manage`は`:read～:destroy`まで全て含んだAliasなのでこれで行ける！
かと思いきや、`create`のときも絞り込みが入ってしまい、`/new`ページが開けません。（でも「Articleを新規作成する」ボタンは表示されます）

### ということでこうしてみる。

```rb:app/models/ability.rb
...

  def owner
    can :create, Article
    can :manage, Article, owner_user: @user
  end

  def admin
    can :manage, Article
  end
```

いけます :+1: :+1: 

### だが待てよ。

上でさりげなくカッコ書きしましたが、

> `/new`ページが開けません。（でも「Articleを新規作成する」ボタンは表示されます）

これだと、`proofreader`（校閲係）にこれをコピーするとき、「createの行だけ消せばいいじゃん」とはなりません。


### /:id用のAlias

そこでこのように、`/:id`なアクション用に新しくAliasを作ります。

```rb:app/models/ability.rb
...
  def initialize(user)
    alias_action :show, :update, :destroy, :to => :manage_own
...
```

そして、こうしてみます。

```rb:app/models/ability.rb
  def owner
    can :create    , Article #新規作成
    can :index     , Article, owner_user: @user #一覧
    can :manage_own, Article, owner_user: @user #/:id系
  end
```

### 「アクションを実行する権限がありません」

Indexが見えなくなりました。 :sob: :sob: 

グローバルメニューからも消えます。 :sob: :sob:
Showのパスを直叩きしても見えません。 :sob: :sob:

なぜかというと、「Indexのページは`:read`でチェックしている」みたいです。
（`:read`には`:index`も含まれてるような気がしますが、そう簡単なことではないらしい・・・。もしかして`:read`は`:index`と`:show`をマージしたもの？）

その他諸々、Article自体にアクセスするかどうかのチェックは`:read`で行ってるみたいです。

### 【完成】

ということで、

こうしてみます。

```rb:app/models/ability.rb
  def owner
    can :create    , Article #新規作成
    can :read      , Article, owner_user: @user #閲覧系
    can :manage_own, Article, owner_user: @user #更新系
  end
```

バッチリ :+1: :+1: 

`proofreader`には`create`以外をコピーすればOKです。

### ではActiveAdmin外でも、

```rb:app/controllers/artivcle.rb
class ArticlesController < ApplicationController
  load_and_authorize_resource

  def show
  end
end
```

### わーい

自分の記事も表示できるけど、***他人の記事にもアクセスし放題*** :sob: :sob: 

この辺はもうよく分からない。考えたくもない。

でもおそらく、ActiveAdminは何でもかんでも`:read`で判定していたからとかそんな感じだろうと当たりをつけまして、こんな感じでパッチしました。

```rb:lib/extensions/cancan/controller_resource.rb
module CanCan
  class ControllerResource
    def authorization_action_with_authorization_read_action
      action = authorization_action_without_authorization_read_action
      [:index,:show].include?(action) ? :read : action
    end
    alias_method_chain :authorization_action, :authorization_read_action
  end
end
```

ActiveAdminと同じように、`:index`と`:show`を`:read`で判定するようにしてみたところ、なんとなく動いてます。（投げやり）

## Filter、Collection

もう少しです。

Indexページのフィルターや編集画面の選択肢なども、CanCanCanで自分のものだけに絞り込むことができます。

```rb:app/admin/article.rb
ActiveAdmin.register Article do
...
  filter :category, as: :select, collection: -> { Category.accessible_by(current_ability,:index) }

  form do |f|
    f.semantic_errors
    f.inputs do
      f.input :category  , as: :select, collection: Category.accessible_by(current_ability,:index)
...
```

```rb:app/models/ability.rb
  def proofreader
    can :read      , Category, article: { owner_user: @user.owner_user }
  end

  def editor
    can :create    , Category
    can :read      , Category, article: { owner_user: @user.owner_user }
    can :manage_own, Category, article: { owner_user: @user.owner_user }
  end

  def owner
    can :create    , Category
    can :read      , Category, article: { owner_user: @user }
    can :manage_own, Category, article: { owner_user: @user }
  end
```

### `accessible_by` めっちゃ便利です

ね？

## 冗長っぽいですが

もっと複雑なことをやろうとすると、ブロックを使いたくなったりしてきます。
でも、閲覧系にはなぜかブロックを使えないので、更新系だけブロックで閲覧系は頑張って何とかする感じになっちゃいます。

例えば、

```rb:app/models/ability.rb
  def proofreader
    articles = Article.assigned_to(@user).select([:id])
    can :read          , [Article], id: articles.map(&:id) #がんばってなんとかしてる
    can [:show,:update], [Article] do |subject, extra_args|
      # なんか複雑なjoinやexistsを駆使してやっとこさ判定
      subject.assigned_to? @user
    end
  end
```

なんてことになってしまった時に、周りと記述が違ってしまわないように、３段構成を基本にしておけばいいかなーくらいな感じです。
そういう意味でいうと、`create`の方も行ごと消さないで`cannot`にして置いておけばいいのかもですが試してないのでやめておきます。

#おわり

`:read`のせいで要らないコードリーディング発生して大変でした。
これはバグなのか意図した動きなのかだけでも知りたいです・・・ Issue投げてみようかな・・・