※トリスタinsideに投稿された記事の再掲載です。
サービス開発部バックエンド開発グループのフサギコ(髙﨑)です。
トリスタが運営しているニコニコ漫画と読書メーターにおいて、AWSとTerraformによるサービスインフラとRuby on Railsによるサーバサイドアプリケーションを主に担当しています。
本記事では、前記事のニコニコ漫画と認可(前編)から引き続き、ニコニコ漫画における新バックエンドの認可機構についてもう少し詳しくお話しします。
前記事の最後でお話ししたとおり、ニコニコ漫画の新バックエンドでは検討したgem(CanCanCan, Pundit, Banken)のいずれも採用せず、認可機構の独自実装を行いました。
本記事ではそれを受け、どのgemも採用しなかった理由、そして実際どのようにして認可処理を実装したかについてお話しします。
なぜどのgemも採用しなかったのか
現行PHPからの移行手順について前記事では
- ビジネスロジックを新バックエンドへ移植
- 新バックエンドを利用するBFFサーバを新設
- React向けBFFサーバ(フロントエンドチーム管轄)
- スマホアプリ向けBFFサーバ(スマホアプリチーム管轄)
- 現行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モデルによる権限表現から認可の是非を判断するには、
- Authorizers::TargetIds::Episode::Createインターフェイスクラスの配列が保持する客体のIDから、認可するべき公式作品の配列を作る
- UserAuthorityモデルを用いて、権限を持っている公式作品を配列から取り除いていく
- 最終的に配列が空になっていれば認可する
ということになります。これを具体的なコードにすると下記のようになります。
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-parameterized、factory_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がどの様な観点から認可という難しい問題を整理し、実装を簡単にしてくれているのかをわかっていただけたのではないかと思います。
長い記事をここまで読んでくださった皆さんの、より良い設計と実装の一助となれるならば幸いです。