ニコニコ漫画と認可(前編)

この記事はトリスタinsideで書かれた記事です。
現在トリスタinsideはBOOK☆WALKER Tech Blogに統合されました。

サービス開発部バックエンド開発グループのフサギコ(髙﨑)です。

トリスタが運営しているニコニコ漫画と読書メーターにおいて、AWSとTerraformによるサービスインフラとRuby on Railsによるサーバサイドアプリケーションを主に担当しています。

本記事では、しばらく前になってしまいましたがGotanda.rb #46 "管理画面/権限"にて発表した、 ニコニコ漫画における新バックエンドの認可機構について、もう少し詳しくお話ししたいと思います。
本記事とは一部構成が異なりますが、下記が発表時に用いたスライドです。

ニコニコ漫画と認可

現在のニコニコ漫画

ニコニコ漫画はニコニコ静画において2010年8月に公開された特設ページを端緒とする、Web漫画連載プラットフォームサービスです。 現行のサーバサイドはPHPで書かれており、当時から続くPHP製Webサービスの例に漏れず、独自フレームワークで実装されています。

しかしながら、運用歴の長いソフトウェアにありがちですが、テストが十分でない、そもそものコード構造が厳しいなどの問題があり、新機能開発の障害になってしまっています。

ニコニコ漫画バックエンドリプレイス計画

そこで、ニコニコ漫画ではサーバサイドアプリケーションの領域の中でもデータの整合性に責任を持つような、 いわゆるSystem of Recordの領域についてRuby on Railsを用いてリプレイスを進めています。
この移行先であるRuby on Railsをこれ以降、新バックエンドと呼ぶことにします。

移行元である現行PHPは、ビジネスロジック・Webフロントエンド・React向けAPIサーバ・スマホアプリ向けAPIサーバの全てを担っています。 これらを

  1. ビジネスロジックを新バックエンドへ移植
  2. 新バックエンドを利用するBFF1サーバを新設
    • React向けBFFサーバ(フロントエンドチーム管轄)
    • スマホアプリ向けBFFサーバ(スマホアプリチーム管轄)
  3. 現行PHPも新バックエンドを利用するように変更

という手順を踏んでSystem of Engagementの領域であるWebフロントエンドのみの担当へ切り離していく計画です。

Webフロントエンドもリプレイスを行って現行PHPを廃止する未来もいずれ訪れるかもしれませんが、少なくとも現在はそこまでは計画に入れられていません。

認証と認可の違いについて

本題に入る前に、認証と認可の違いについて簡単に述べます。

認証 authentication 認可 authorization
"それ"が誰あるいは何であるかを確かめること "それ"が行いたい行動を許可すること

どちらもauthという頭文字から始まっていたり、OAuthのはしりであるTwitterのOAuthがこの2つの区別をできていないため混同されがちですが、この2つは本質的に異なる行為です。

本記事の執筆時点では認証をまだ現行PHPが担当しているため、新バックエンドは認可のみを行うものとします。

認可処理の最小のインターフェイスについて考える

認可とは、前節で述べた通り「"それ"が行いたい行動を許可すること」です。 つまり、認可処理とは認可するかどうかを判断すること、言い換えれば「何が何に対して何を行いたいか」という要求に応じてそれを許可するかどうかを決めることです。
したがって認可処理の入力は主体・操作・客体の3個、出力はそれを認可するかしないかのbooleanが1個、と言えます。

要求を却下するときに理由を通知したい、なども考えられますがそれはあくまで追加要件であって、最小のインターフェイスではありません。

ニコニコ漫画における認可が特に必要な箇所

ニコニコ漫画における漫画作品は、一般のニコニコのユーザーの皆さまからご投稿いただくユーザー漫画と、様々な出版社の編集部さまからご連載いただく公式漫画の二種類に大別できます。

ユーザー漫画についても投稿者だけが編集できるという権限判定は当然必要です。
しかし、公式漫画は各編集部さまやトリスタの担当者など複数の主体が登場し、単純な公開/非公開ではなく無料公開する期間、有料販売する期間をエピソードごとに設定できるなど様々な操作、条件が絡み合うためより複雑な判定が必要です。

Railsにおける認可系gem3種の概観

本節では、前節で述べたような複雑な判定が必要な公式漫画の認可処理を実現するために検討した、Railsにおいて比較的有名と思われる認可系gem3種について、その特徴を概観します。

CanCanCan

CanCanCanは、先に述べた認可処理の3つの入力のうち主体を中心に、それがどんなことをできるのかという観点で記述するgemです。

Abilityクラスと呼ばれるクラスを下記のようにして作成し、その中でどのような主体が何に対して何を行えるかを記述します。

class Ability
  include CanCan::Ability

  def initialize(user)
    can :read, Post, public: true

    if user.present?  # additional permissions for logged in users (they can read their own posts)
      can :read, Post, user_id: user.id

      if user.admin?  # additional permissions for administrators
        can :read, Post
      end
    end
  end
end

そのうえでcontrollerのアクションメソッド内でauthorize!メソッドに客体であるPostクラスのインスタンスを操作とともに渡します。
するとCanCanCanは主体をcurrent_userメソッドから検出してAbilityのインスタンスを作成し、渡された操作と客体と合わせて認可処理を行います。

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])

    authorize! :read, @post
  end
end

また、客体にActiveRecordのクラスを指定した場合、その主体がread権限を持つレコードのみを取得するようなscopeメソッドが追加されるため便利2です。

  ability = Ability.new(current_user)
  Post.accessible_by(ability)

一方で、Abilityクラスのインスタンス化の際に全ての権限を評価するため、権限が複雑化すると認可処理を行いたい客体に関係ない権限まで評価する分の処理時間が無駄になってしまう懸念があります。

Pundit

Punditは客体を中心に、それを操作できるのは何か、という観点で記述するgemです。

例えばPostという名前のActiveRecordクラスがあるとき、これに対応するPostPolicyクラスを下記のようにして定義します。 操作に対応する認可処理は、booleanな値を返すPolicyクラスのインスタンスメソッドとして表現します。

class Post < AppricationRecord
  # 中略
end

class ApplicationPolicy
  attr_reader :user, :record
  def initialize(user, record)
    @user = user
    @record = record
  end
end

class PostPolicy < ApplicationPolicy
  def update?
    user.admin? or not record.published?
  end
end

そのうえでcontrollerのアクションメソッド内でauthorizeメソッドにPostクラスのインスタンスを引数として渡して呼び出します。
するとPunditはPostクラスであることからPostPolicyのインスタンスを作成し、主体はcurrent_userメソッドから、操作はアクションメソッド名から検出して認可処理を行います。

class PostsController < ApplicationController
  def update
    @post = Post.find(params[:id])

    authorize @post

    if @post.update(post_params)
        redirect_to @post
    else
        render :edit
    end
  end
end

これは客体に対する操作とその認可基準を簡潔に記述でき、認可判定の呼び出しも簡単です。 一方で、ソフトウェアが複雑化し、同じ客体に対してcontrollerごとに異なる内容の認可処理を行いたい場面が出てきたときに、記述が難しくなる問題があります。

PunditもCanCanCanと同様、権限を持つレコードのみを取得するようなscopeメソッドを表現できます3

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end
end

class PostsController < ApplicationController
  def index
    @posts = policy_scope(Post)
  end

  def show
    @post = policy_scope(Post).find(params[:id])
  end
end

Banken

Bankenは操作を中心に、それをできるのは何が何に対して行おうとしたときか、という観点で記述するgemです。

このgemは、Punditの項において述べた「同じ客体に対してcontrollerごとに異なる内容の認可処理を行いたい場面が出てきたときに、記述が難しくなる」問題の解決を試みています4

PostsControllerというcontrollerがあるとき、これに対応するPostsLoyaltyクラスを下記のようにして定義します。 操作に対応する認可処理は、booleanな値を返すLoyaltyクラスのインスタンスメソッドとして表現します。

class ApplicationLoyalty
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end
end

class PostsLoyalty < ApplicationLoyalty
  def update?
    user.admin? || record.unpublished?
  end
end

そのうえでcontrollerのアクションメソッド内でauthorize!メソッドにPostクラスのインスタンスを引数として渡して呼び出します。
するとBankenは呼び出し元がPostsControllerであることからPostsLoyaltyクラスのインスタンスを作成し、主体はcurrent_userメソッドから、操作はアクションメソッド名から検出して認可処理を行います。

class PostsController < ApplicationController
  def update
    @post = Post.find(params[:id])

    authorize! @post

    if @post.update(post_params)
      redirect_to @post
    else
      render :edit
    end
  end
end

認可処理が記述されたクラスの中でどれを呼び出すかを、渡された客体のクラス名ではなく呼び出し元controllerのクラス名から決定する点がPunditと異なります。
これによって、「同じ客体に対してcontrollerごとに異なる内容の認可処理を行いたい」という場面で異なるLoyaltyクラスを呼び分けることができるようになっています。

一方で、権限を持つレコードのみを取得するようなscopeメソッドはbankenでは用意されていません[^4]。

ニコニコ漫画の新バックエンドでは何を採用したか

前節でRailsにおける認可系gem3種を概観しました。
では、ニコニコ漫画の新バックエンドではどのgemを採用したのでしょうか。
結論から言うと、どのgemも採用せず、独自実装を行いました。

どのgemも採用しなかった理由、そして実際どのようにして認可処理を実装したかについてもお話ししたかったのですが、現時点でもかなり長い記事となってしまっています。
そのため、この記事は前編として、また後日続きをお話ししたいと思います。


  1. Backend For Frontend。データの整合性に責任を持つバックエンドサーバから情報を集め、ユーザーの手元で情報の見せ方を担当するReactやスマホアプリが利用しやすいデータ構造に組み立てる役割のAPIサーバのこと。

  2. Fetching records

  3. Scopes

  4. The difference between Banken and Pundit (Japanese))