GraphQLを使っているときに様々な処理で認可させたい事があると思います。
- このQueryはログインユーザーのみ実行できるようにしたい
- このMutationは管理者のみ実行できるようにしたい
- このQueryは自分の所有しているデータのときだけ返却するようにしたい
- このFieldは自分の所有しているデータのときだけ返却するようにしたい
当初はgraphql-rubyの知識が乏しかったので取得や更新処理の中で認可する処理を呼び出していたのですが、graphql-rubyのドキュメントを改めて読み直したところ、認可のためのメソッド(authorized?)がある事がわかったので動作検証を兼ねて記事を書きました。
2021/02/10 追記
コメントでいただきましたが、類似のメソッドにready?
というものがあるようです。
ドキュメントにも記載されています。
https://graphql-ruby.org/mutations/mutation_authorization.html
ready?とauthorized?の違いはargumentsがロードされる前に呼ばれるか否かです。
ready?はargumentsがロードされる前に呼ばれ、authorized?はロード後に呼ばれるようです。
そのため、argumentsを使わないチェックの場合はready?で行った方が効率が良さそうです。
graphql-rubyについて
Ruby(Rails)でGraphQLを簡単に使えるようにしてくれるGemです。
https://github.com/rmosolgo/graphql-ruby
細かいところは実際に試してみないとわからないことも多いですが、ドキュメントが充実していて素晴らしいです。
https://graphql-ruby.org/guides
この記事を書いている時点では、graphql: 1.11.1
を使っています。
まだガンガンバージョンアップしているGemなので、バージョンが違うと大幅に動作が変わっている可能性があるのでご注意ください。
認可の実装例
最初に挙げた4つのパターンの実装例を説明します。
前提条件
認可に必要なログインユーザーの情報はcontextに格納していることとします。
認証についてはこの記事の本筋からの逸れるので説明は省略します。
# ログインユーザーの情報はcontext[:current_user]に格納
# 未ログインの場合はnil
context = { current_user: current_user }
このQueryはログインユーザーのみ実行できるようにしたい
ここでは『review_idを指定して該当するReviewTypeを返却するクエリー』を実装します。
認可を入れる前
認可を実装する前にReviewTypeを取得するクエリーを実装します。
module Types
class QueryType < Types::BaseObject
field :review, resolver: Resolvers::ReviewResolver
end
end
module Resolvers
class ReviewResolver < BaseResolver
type Types::ReviewType, null: true
argument :review_id, Int, required: true
def resolve(review_id:)
Review.find_by(id: review_id)
end
end
end
module Types
class ReviewType < BaseObject
field :id, ID, null: false
field :title, String, null: true
field :body, String, null: true
field :secret, String, null: true
field :user, Types::UserType, null: false
end
end
module Types
class UserType < BaseObject
field :id, ID, null: false
field :name, String, null: false
field :email, String, null: false
end
end
認可を実装
それでは先ほど実装した処理に『ログインユーザーのみ実行できる』という制約を追加します。
authorized?を使わない実装
以前の私はresolveメソッドでReviewを取得する前にログインチェックする実装を入れていました。
まずは様々なResolverから使えるようにBaseResolverにログインチェックメソッドを実装します。
context[:current_user]が入っていない場合はエラーを発生させます。
ちなみに、GraphQL::ExecutionError
を使うとraiseするだけでレスポンスをGraphQLのエラー形式に変換してくれます。
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
def login_required!
# ログインしていなかったらraise
raise GraphQL::ExecutionError, 'login required!!' unless context[:current_user]
end
end
end
次にBaseResolverのログインチェックを処理の最初に呼び出すようにします。
def resolve(review_id:)
+ # 処理の最初にログインチェックを行う
+ login_required!
Review.find_by(id: review_id)
end
GraphiQLで未ログインの状態で実行すると次のようになります。
この方法でもやりたいことは実現できているのですが、ログイン必須のResolverは処理の最初に必ずlogin_required!
を書かなければいけません。
controllerのbefore_actionのように本処理が呼ばれる前に自動で認可してくれる方法はないのかをずっと探していました。
authorized?を使う実装
graphql-rubyのガイドを改めて読んでいるとauthorized?というメソッドがあることに気づきました。
これを使うとresolveメソッドの前に認可を行い、実行可否を制御することができるようです。
下記はmutationに追加するガイドですが、Resolverにも同じように追加できます。
https://graphql-ruby.org/mutations/mutation_authorization.html
ログイン必須のResolverは汎用的に使えそうなので、ログイン必須のResolverが継承するlogin_required_resolverを作りました。
authorized?のパラメーター(args)にはresolveと同じパラメーターが格納されます。
module Resolvers
class LoginRequiredResolver < BaseResolver
def authorized?(args)
context[:current_user].present?
end
end
end
review_resolverはlogin_required_resolverを継承するように修正します。
他の実装は認可を追加する前と同じです。
- class ReviewResolver < BaseResolver
+ class ReviewResolver < LoginRequiredResolver
GraphiQLで未ログインの状態で実行すると次のようになります。
authorized?の結果がfalseの場合はエラー情報はなくdata: null
だけ返却されるようになりました。
ガイドにも記載がある通り、authorized?がfalseの場合はdata: null
だけを返却するのがデフォルトの挙動のようです。
nullを返却するという仕様で問題なければこのままで良いですが、認可されない場合はエラー情報も返却するように変更してみます。
エラー情報を追加する方法は簡単で、authorized?の中でGraphQL::ExecutionErrorをraiseすればできます。
ちなみに成功時はtrueを明示的に返却しないと成功と認識されないので注意が必要です。
module Resolvers
class LoginRequiredResolver < BaseResolver
def authorized?(args)
# 認可できない場合はGraphQL::ExecutionErrorをraise
raise GraphQL::ExecutionError, 'login required!!' unless context[:current_user]
true
end
end
end
GraphiQLで未ログインの状態で実行すると次のようになります。
これでauthorized?を使った場合でもエラー情報を返却することができました。
authorized?を使った場合、resolveメソッドでは認可の処理を書く必要がなくなるのでシンプルに書くことができます。
(今回の例はかなりシンプルな実装なのでそこまで差はありませんが・・・)
このMutationは管理者のみ実行できるようにしたい
ここでは『review_idを指定して該当するReviewのtitleとbodyを更新するMutation』を実装します。
認可を入れる前に
認可を実装する前にReviewを更新するMutationを実装します。
1つ前の例で使ったReviewTypeなどそのまま使うクラスは省略します。
module Types
class MutationType < Types::BaseObject
field :update_review, mutation: Mutations::UpdateReview
end
end
module Mutations
class UpdateReview < BaseMutation
argument :review_id, Int, required: true
argument :title, String, required: false
argument :body, String, required: false
type Types::ReviewType
def resolve(review_id:, title: nil, body: nil)
review = Review.find review_id
review.title = title if title
review.body = body if body
review.save!
review
end
end
end
GraphiQLで実行すると次のようになり、Reviewデータが更新されます。
認可を実装
Mutationでも先程の例と同様にauthorized?を使うことができます。
下記のガイトに記載されています。
https://graphql-ruby.org/mutations/mutation_authorization.html
管理者しか利用できないMutationが継承する親クラスを作って継承するようにします。
module Mutations
class BaseAdminMutation < BaseMutation
def authorized?(args)
raise GraphQL::ExecutionError, 'login required!!' unless context[:current_user]
raise GraphQL::ExecutionError, 'permission denied!!' unless context[:current_user].admin?
super
end
end
end
- class UpdateReview < BaseMutation
+ class UpdateReview < BaseAdminMutation
Mutationのauthorized?もfalseを返却するだけだとエラー情報は返却されず、dataがnullになり更新処理が実行されないようになります。
Resolverはそれでも良さそうですがMutationはエラー情報を返却しないとよくわからないと思うので、こちらもGraphQL::ExecutionErrorをraiseするように実装しました。
ちなみにガイドを読むと下記のように戻り値にerrorsを返却することでエラー情報を返す方法もあるようです。
試してみましたが下記の方法ではerrors配下のlocationsやpathは返却されませんでしたが、errorsのmessageは返却できました。
メッセージだけ返却できればよいのであればどちらの方法で実装しても良さそうです。
def authorized?(employee:)
if context[:current_user]&.admin?
true
else
return false, { errors: ["permission denied!!"] }
end
end
GraphiQLで管理者権限を持っていないユーザーが実行すると次のようになります。
もちろんエラーの場合は更新処理は実行されません。
このQueryは自分の所有しているデータのときだけ返却するようにしたい
ここでは最初に作った『review_idを指定して該当するReviewTypeを返却するクエリー』を基に改修します。
最初に作ったものはログイン状態のみ確認していましたが、今回はReviewが自分の所有物か?のチェックを追加します。
ログインチェックと同じauthorized?に実装してみる
ログインチェックと同じauthorized?にチェックを追加できればよいのですが、今回のチェックはRevewを取得した後でないとチェックできません。
authorized?でもreview_idは引数で受け取るのでReviewを取得することもできるのですが、そうするとresolveの役割が曖昧になります。
実際に実装してみます。
def authorized?(args)
raise GraphQL::ExecutionError, 'login required!!' if context[:current_user].blank?
+ # この時点でreviewの取得が必要
+ review = Review.find_by(id: args[:review_id])
+ return false unless review
+ raise GraphQL::ExecutionError, 'permission denied!!' if context[:current_user].id != review.user_id
true
end
authorized?でReviewの取得が必要になります。
resolveメソッドでも取得するので、ここでも取得すると非効率な気がしますね。
では、resolve側にチェックを実装するとどうでしょうか?
def resolve(review_id:)
- Review.find_by(id: review_id)
+ review = Review.find_by(id: review_id)
+ raise GraphQL::ExecutionError, 'permission denied!!' if context[:current_user].id != review.user_id
+ review
end
こちらの方がauthorized?で実装するより効率は良さそうですが、authorized?にチェック処理を切り出すことでデータ取得処理のみ記載していたresolveにまたチェック処理が入ってしまいました。
当初はデータ取得後にしかチェックできないものがresolveでチェックするしかないと思っていたのですが、authorized?はReviewTypeにも定義できることを知ったのでReviewTypeに定義してみます。
ReviewTypeでチェックする
ReviewTypeでチェックするとはどういうことなのか?
実際に実装してみます。
ReviewTypeは誰でも使えるようにしておきたいので、MyReviewTypeという自分しか閲覧できない制約をつけたReviewTypeを作ります。
module Types
class MyReviewType < ReviewType
def self.authorized?(object, context)
raise GraphQL::ExecutionError, 'permission denied!!' if context[:current_user].id != object.user_id
true
end
end
end
ガイドにも記載されていますが、Typeで使うauthorized?はobjectとcontextを引数に受け取ります。
あと、クラスメソッドなので注意が必要です。
https://graphql-ruby.org/authorization/authorization.html
あとはレスポンスのTypeをMyReviewTypeにするだけです。他の修正は不要です。
- type Types::ReviewType, null: true
+ type Types::MyReviewType, null: true
GraphiQLで自分以外のReviewを指定すると次のようになります。
これでresolveメソッドには認可の処理を書く必要がなくなるのでシンプルに書くことができました。
また、レスポンスをMyReviewTypeにすることでスキーマ定義を読むだけで、このクエリーはMyReviewTypeを返却する=「自分しか閲覧できない」ということが明確になるので良いと思います。
このFieldはログインユーザー自身のデータのときだけ返却する
1つ前の例ではMyReviewTypeを定義してレスポンス全体を自分のデータのときしか見れないようにしました。
しかし、全部ではなく特定のフィールドだけ見れないようにしたいこともあると思います。
ReviewTypeを再掲します。
ここではsecretカラムは自分のデータしか見れないようにしたいと思います。
module Types
class ReviewType < BaseObject
field :id, ID, null: false
field :title, String, null: true
field :body, String, null: true
field :secret, String, null: true # <- これを自分の場合のみ見えるようにする
field :user, Types::UserType, null: false
end
end
ガイドを読むとfieldにもauthorized?が実装できるようなのですが、1つのfieldだけをカスタマイズするのは難しそうなのでここではauthorized?を使わずに実装することにしました。
https://graphql-ruby.org/authorization/authorization.html
fieldのガイドはこちら
https://graphql-ruby.org/fields/introduction.html#field-parameter-default-values
下記のようにfield名と同じメソッドを定義すると、そちらが呼び出されるようになります。
そのメソッド内に認可を実装しました。
module Types
class ReviewType < BaseObject
field :id, ID, null: false
field :title, String, null: true
field :body, String, null: true
field :secret, String, null: true
field :user, Types::UserType, null: false
# field名のメソッドを定義すると呼び出される
def secret
# ログインユーザーとレビューを書いたユーザーが違う場合、nilを返却
return if object.user_id != context[:current_user].id
object.secret
end
end
end
GraphiQLで自分以外のReviewを指定すると次のようになります。
secretはnullが返却されています。
Resolverにこのチェックを実装するとReviewTypeを使うすべてのResolverがsecretの考慮をしなければいけなくなりますが、ReviewTypeに実装することで個別のResolverはsecretのアクセス制御を考える必要がなくなります。
最後に
graphql-rubyを使い始める前にもガイドは一通り目を通したつもりだったのですが、authorized?の存在は見落としていました・・・
authorized?以外にもまだまだ気づいていない便利な機能がありそうですね。
また、今はなかったとしてもがんがんバージョンアップされており、これからも新しい機能が追加される可能性も高いので、これからもgraphql-rubyの動向をチェックしていきたいと思います。