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

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

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

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

本記事では、前記事のニコニコ漫画と認可(前編)から引き続き、ニコニコ漫画における新バックエンドの認可機構についてもう少し詳しくお話しします。
前記事の最後でお話ししたとおり、ニコニコ漫画の新バックエンドでは検討したgem(CanCanCan, Pundit, Banken)のいずれも採用せず、認可機構の独自実装を行いました。
本記事ではそれを受け、どのgemも採用しなかった理由、そして実際どのようにして認可処理を実装したかについてお話しします。

なぜどのgemも採用しなかったのか

現行PHPからの移行手順について前記事では

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

と簡略化して書きましたが、実際には部分機能ごとに順次移行しています。
そのため移行期間中は当然、現行PHPと新バックエンドがデータベース(以下DB)を共有しつつ並行稼働することになります。

モデルと必要権限が一対一で対応しない

新バックエンドは現行PHPとDBを共有しながらの順次リプレイスですから、テーブル構造は当然現行のものを引き継ぎます。

Railsにおいてテーブル構造は原則としてモデルと一対一で対応するものです。
しかし、現行PHPではユーザー漫画/公式漫画、無料公開期間/有料販売期間などをActiveRecordに用意されているものとは互換性がない表現形式の単一テーブル継承で表現していました。
そこで、新バックエンドでは基底クラスとして漫画作品と公開期間を定義し、それを継承するようなユーザー漫画/公式漫画、無料公開期間/有料販売期間を表現する派生クラスを定義せざるを得ませんでした。

漫画作品を例に取ると下記のような構造になっています。

class Comic < ApplicationRecord
  def official?
    is_official
  end

  def to_each_type
    if official?
      becomes(Comic::OfficialComic)
    else
      becomes(Comic::UserComic)
    end
  end

  # ユーザー漫画と公式漫画に共通のロジック
end

class Comic::UserComic < Comic
  # ユーザー漫画に固有のロジック
end

class Comic::OfficialComic < Comic
  # 公式漫画に固有のロジック
end

これによってPunditが前提とする1客体1認可処理の構造が崩れるため、Punditの採用は難しいと考えました。

アクションメソッドと必要権限も対応するとは限らない

新バックエンドは文字通りバックエンドですから、できる限りREST的なパス構造を維持したいです。
しかし、現行PHPから受け継いだAPIにはPATCHリクエストにおいて、パスパラメータのidの形式によって異なるモデルを操作するようなものが存在しました。

class HogesController < ApplicationRecord
  def update
    if hoge_id?(params[:id])
      Hoge.find(params[:id]).update!(update_params)
    else
      Fuga.find(params[:id]).update!(update_params)
    end
  end
end

これによってBankenが前提としている1操作1認可処理の構造も崩れるため、Bankenもまた、採用は難しいと考えました。

バルク(一括)操作がある

現行PHPから受け継いだAPIには1リクエストで複数のモデルを一括作成するエンドポイントも存在しました。

class FreeViewingTermsController < ApplicationRecord
  def create
    ActiveRecord::Base.transaction do
      create_params[:requests].each do |create_param|
        FreeViewingTerm.create!(create_param)
      end
    end
  end
end

これによってBankenが前提としている1操作1客体の構造も崩れるため、Bankenの採用は更に難しいと考えました。

CanCanCanの記法で既存の権限を表現しきれるかわからなかった

PunditとBankenの採用が難しそうとなると、残る候補はCanCanCanです。
しかし、現行PHPの権限構造は場当たり的に増改築されてきたことで、レベルベースのような、ロールベースのような、そうでもないような、中途半端な構造になってしまっています。

CanCanCanではcanメソッドの第三引数以降で客体に対する条件を設定できるものの、これのみを使って既存の権限構造を表現できるか、設計段階では確信が持てませんでした。
したがってCanCanCanの採用も、一旦見送ることにしました。

認可処理を独自実装するにあたっての方針

ここまでで、どのgemも採用しなかった複数の理由について詳しく述べました。
これらは簡単に言えば「新バックエンドは現行PHPとDBを共有したリプレイスである以上、データベースもAPI定義も現行PHPのものをいくらかは引き継がざるを得ず、Rails wayから外れることが最初から確定していた」という一点に尽きます。

したがって、認可処理を独自実装するうえでは認可部をRails wayから完全に隔絶し、コードの独立性を高めるという方針を立てました。

ニコニコ漫画の新バックエンドにおける認可機構

本節では、ニコニコ漫画の新バックエンドが認可処理をどのようにして実装しているかについてお話しします。

まず、利用する側からは下記のようにして使います。

class EpisodesController
  def create
    Authorizer.authorize!(
      user_id: parse_authorization_header['user_id'],
      action: Enums::Authorizers::Actions::Episode::CREATE,
      target_ids: Authorizers::TargetIds::Episode::Create.new(comic_id: create_params['comic_id'])
    )

    @episode = Episodes.create!(create_params)
  end
end

単一のエントリーポイントから、具体的な認可処理を実装したAuthorizerへ委譲する

一方、利用される側であるAuthorizerは下記のようになっています。

module Authorizer
  def self.authorize(action:, user_id:, target_ids: [])
    target_ids = Array(target_ids)
    case action.target
    when Enums::Authorizers::Target::EPISODE
      Authorizer::EpisodeAuthorizer.authorize(user_id: user_id, action: action, target_ids: target_ids)
    when Enums::Authorizers::Target::OFFICIAL_COMIC
      Authorizer::OfficialComicAuthorizer.authorize(user_id: user_id, action: action, target_ids: target_ids)
    # その他の客体のAuthorizer
    end
  end

  def self.authorize!(action:, user_id:, target_ids: [])
    raise NicomangaError::ResourceForbiddenError unless authorize(user_id: user_id, action: action, target_ids: target_ids)

    true
  end
end

このようにrootとなるAuthorizerのauthorizeメソッドが主体と操作と客体(の配列)を取り、操作から導かれる客体の種類に対応するAuthorizerへ委譲します。
委譲された、客体に対応するAuthorizerが最終的にbooleanを返すことによって、前記事で述べた「認可処理の最小のインターフェイス」を満たしています。

客体に対する操作を列挙すれば、客体は操作から逆引きできる

前項でも少し触れましたが、客体の種類は操作から導けます。

class Enums::Authorizers::Target < Inum::Base
  define :OFFICIAL_COMIC
  define :USER_COMIC
  define :EPISODE
  # その他の客体
end

class Enums::Authorizers::Actions::Episode < Inum::Base
  def target
    Enums::Authorizers::Target::EPISODE
  end

  define :CREATE
  define :DESTROY
  define :INDEX
  define :SHOW
  define :UPDATE
end

このように、新バックエンドでは列挙型を扱う際にActiveRecordのenumではなく、Inumを利用しています。
これは上記のEnums::Authorizers::Targetのような、特定のモデルのカラムに紐づかない列挙型も作成できるため便利1です。

操作によって異なる、必要なIDの種類を表現する

ところで、前項のEnums::Authorizers::Actions::Episodeの例ではCREATE・DESTROY・INDEX・SHOW・UPDATEの5種の操作を定義しており、これはRailsのroutes.rbでresourcesを使ってパスを定義したときの各アクションメソッドに対応しています。
これらは使うIDの種類によって、ComicモデルのIDを使うCREATE・INDEXと、EpisodeモデルのIDを使うDESTROY・SHOW・UPDATEの2つに分けられます。 しかし、動的型付けであるRubyでは、客体のIDをそのまま渡してしまうと正しくない種類のIDを渡してしまったときに気付けません。

そこで、本実装では下記のようにインターフェイスとなるクラスを各操作に対応する形で定義し、これを通じて客体のIDを受け渡すことにしました。
これにより、渡す側が間違った種類のIDを渡そうとしたとき、インターフェイスクラスのインスタンスをnewする際のキーワード引数名によってArgumentErrorがraiseされるため、使い方の間違いに気付けるようになっています。 渡される側が間違った種類のIDを取り出そうとしたときも、attr_readerで定義されていないメソッドを呼んだ結果NoMethodErrorがraiseされるので、同様に使い方の間違いに気付けます。

class Authorizers::TargetIds::Episode::Create
  attr_reader :comic_id

  def initialize(comic_id:)
    @comic_id = comic_id
  end

  def eql?(other)
    hash == other.hash
  end

  def hash
    {
      comic_id: @comic_id
    }.hash
  end
end

現行のニコニコ漫画のデータベースにおける権限付与の表現方法

あとはそれぞれの客体に対応するAuthorizerにおいて具体的な認可処理を行うわけですが、その前に現行のニコニコ漫画のデータベースにおける権限付与の表現方法についてお話します。
それぞれの主体に付与した権限はUserAuthorityモデルで表現されていて、実際にはInumで表現されているため本物のコードではありませんが、下記のようなイメージです。

class UserAuthority < ApplicationRecord
  attribute :user_id, :integer # 権限を付与する主体のID

  enum target_type: [ # 権限を付与する範囲
    :COMIC,                # 漫画作品単体
    :OFFICIAL_COMIC_GROUP, # 公式漫画作品グループ
    :ALL_OFFICIAL_COMIC,   # 全公式漫画作品
  ]
  attribute :target_id, :integer # 権限を付与する範囲のID

  enum action: [ # 付与する権限の種類
    :WATCH,   # 非公開状態の作品の閲覧ができる
    :PUBLISH, # 公開期間の設定ができる
    :EDIT,    # 新規エピソードの作成と原稿納入、作品のメタ情報の変更などができる
    :ALL,     # 全ての操作ができる
    # その他の権限
  ]
end

このように、UserAuthorityモデルは

  • 権限を付与する主体のIDであるuser_id
  • 権限を付与する範囲を示すtarget_type
  • 権限を付与する範囲のIDであるtarget_id
  • 付与する権限の種類を示すaction

の4つからなります。
target_typeの最小単位は漫画作品で、より大きな付与範囲として作品グループと公式漫画全作品があります。

次にactionですが、本記事の「なぜどのgemも採用しなかったのか」の節で「場当たり的に増改築されてきたことで、レベルベースのような、ロールベースのような、そうでもないような、中途半端な構造」と書いたのはこれのことです。
PUBLISH・EDIT・ALLは暗黙にWATCHを含みますが、PUBLISHとEDITは互いにもう片方を含みません。
更に、具体的な説明が難しいのですが、「その他の権限」の部分には主体が誰かだけでなく客体の状態によっても成立するかどうかが変わり、しかも成立した際にはWATCH・PUBLISH・EDITを暗黙に含むようなものまで存在します。 このあたりの複雑さは将来的に整理していきたい点です。

客体に対応したAuthorizerにおける、具体的な認可処理の実装

前項まででお話した内容から、UserAuthorityモデルによる権限表現から認可の是非を判断するには、

  1. Authorizers::TargetIds::Episode::Createインターフェイスクラスの配列が保持する客体のIDから、認可するべき公式作品の配列を作る
  2. UserAuthorityモデルを用いて、権限を持っている公式作品を配列から取り除いていく
  3. 最終的に配列が空になっていれば認可する

ということになります。これを具体的なコードにすると下記のようになります。

module Authorizer::EpisodeAuthorizer
  def self.authorize(user_id: user_id, action: action, target_ids: target_ids)
    case action.target
    when Enums::Authorizers::Actions::Episode::CREATE
      create?(user_id: user_id, action: action, target_ids: target_ids)
    when Enums::Authorizers::Actions::Episode::UPDATE
      update?(user_id: user_id, action: action, target_ids: target_ids)
    # その他の操作の認可処理への振り分け
    end
  end

  def self.create?(user_id:, action:, target_ids:)
    actions_of_user_authority_to_be_allow = [
      Enums::UserAuthority::Action::Edit,
      Enums::UserAuthority::Action::All,
    ]

    authorities = UserAuthority.where(user_id: user_id, action: actions_of_user_authority_to_be_allow)
    return true if authorized_by_all_official_comic_authority?(authorities, actions_of_user_authority_to_be_allow)

    official_comics = official_comics_by_comic_ids(comic_ids: target_ids.map(&:comic_id))

    official_comics -= authorized_official_comics_by_official_comic_group_authority(authorities, official_comics, actions_of_user_authority_to_be_allow)
    official_comics -= authorized_official_comics_by_comic_authority(authorities, official_comics, actions_of_user_authority_to_be_allow)

    official_comics.empty?
  end
  # その他の操作の認可処理の実装
end

Authorizerをテストする

最後にこのAuthorizerの入出力をテストすれば、将来的に動作の変更やリファクタを行う際にも安心できます。

新バックエンドではテストフレームワークとしてrspecを使っていて、rspec-parameterizedfactory_botと組み合わせて下記のようにテストしています。

ここで、subjectがAuthorizer::EpisodeAuthorizerでなくエントリーポイントであるAuthorizerなのは、利用者はAuthorizerを通じてしか使わないからです。
将来的にリファクタによってAuthorizer::EpisodeAuthorizerが消滅する未来があるかもしれず、そのような場合であっても利用者から使われる単一のエントリーポイントであるAuthorizer.authorizeからテストしていれば、テストを書き換える必要はありません。

describe Authorizer do
  subject do
    Authorizer.authorize(
      user_id: request_user.id,
      action: action,
      target_ids: target_ids
    )
  end

  let :comic do
    create(:official_comic)
  end

  describe 'Episodeについて' do
    describe 'CREATEの場合' do
      before :each do
        authority
      end

      let :request_user do
        create(:user, id: comic.user_id + 1)
      end

      let :action do
        Enums::Authorizers::Actions::Episode::CREATE
      end

      let :target_ids do
        Authorizers::TargetIds::Episode::Create.new(comic_id: comic.id)
      end

      let :authority do
        nil
      end

      it '権限がなければ認可されない' do
        expect(subject).to eq(false)
      end

      context 'comic_authorityについて' do
        let :authority do
          create(:comic_authority,
                 user: request_user,
                 target_id: comic.id,
                 action: authority_action)
        end

        where(:authority_action, :expected_result) do
          [
            [Enums::UserAuthority::Action::Watch, false],
            [Enums::UserAuthority::Action::Publish, false],
            [Enums::UserAuthority::Action::Edit, true],
            [Enums::UserAuthority::Action::All, true],
          ]
        end

        with_them do
          it do
            expect(subject).to eq(expected_result)
          end
        end
      end

      # official_comic_group_authorityとall_official_comic_authorityについては省略
    end
  end
end

本実装のメリットと課題

ここまでニコニコ漫画の新バックエンドで独自実装した認可機構の構造と、そのテスト方法についてお話ししました。

このような構造を採ることによって、

  • 特定のタイミングに依存せず認可の是非を取得できる
  • 認可の三要素(主体・客体・操作)のうち、客体と操作の追加削除が簡単にできる
  • Railsから完全に隔絶されているので、最悪の場合客体がActiveRecordモデルとして実際に存在せずとも認可を実装できる

というメリット2があります。 その一方で、

  • フロントエンドとして、特定の権限を持っているユーザにのみ表示したい機能があるので、特定の作品に対してどの権限を持っているかの一覧が欲しい
  • 権限を持っているリソースを一括取得したい

という要求は本実装だけでは満たせません。
これらの要求をどのように満たすかは今後の課題と言えます。

まとめ

前編では、下記についてお話しました。

  • ニコニコ漫画の現行PHPが抱える課題
  • 新バックエンドへのリプレイス計画
  • 認証と認可の違い
  • 認可の最小のインターフェイス
  • Railsにおける認可系gem3種の概観
    • CanCanCan
    • Pundit
    • Banken

そして本記事ではそれを受けて、下記についてお話しました。

  • 既存gemを使わず独自実装した理由
  • 独自実装するうえでの方針
  • 独自実装の具体的内容
    • 利用側から見た使い方
    • エントリーポイントとなるAuthorizerは操作によって客体に対応する各々のAuthorizerへ振り分ける
    • 客体は操作から逆引きできる
    • 客体のIDの種類をインターフェイスクラスによって表現する
    • 現行のニコニコ漫画のデータベースにおける権限付与の表現方法
    • 客体に対応したAuthorizerにおける、具体的な認可処理の実装
    • Authorizerをテストする方法
  • 本実装のメリットと課題

Railsを使って新規に、Rails wayにしたがってサービス開発をする場合にはここまでの大掛かりな独自実装は必要ないでしょう。素直に、前編にて挙げた認可系gem3種のいずれかを使えばよいと思います。
一方で本記事の内容から、それぞれのgemがどの様な観点から認可という難しい問題を整理し、実装を簡単にしてくれているのかをわかっていただけたのではないかと思います。
長い記事をここまで読んでくださった皆さんの、より良い設計と実装の一助となれるならば幸いです。


  1. もちろん、DB上の値を第二引数で指定したうえでモデルのカラムに紐づければ、booleanを返すような判定メソッドを生やしたり、カラムへ代入するときにInumを渡すこともできます

  2. 三つめはメリットと言っていいのか迷うところですが…どのような場合にも対応できる柔軟性を持っている、と考えればメリットと言えなくもありません