※この記事は、Speee Advent Calendar23日目の記事です。 昨日の記事はこちら
どうも、最近は専らgraphql-rubyと戯れている石井です。
みなさん、graphql-rubyを使っていて「このTypeのこのフィールドはAdminユーザにしか見せたくないんです!」といったようにアクセス制限したくなる時が3日に1回ぐらいはやって来るのではないでしょうか。
今回はRailsとgraphql-rubyでAPIを作る際にgraphql-guardを使ってシンプルなアクセス制限を実現する方法をソースコードを織り交ぜつつ紹介してみたいと思います。
※本記事で記載するコードは全て実装の雰囲気を伝えることが目的のサンプルコードなのでご了承ください。
想定する環境
この記事では下記のバージョンの言語、ライブラリの利用を想定しています。
- Ruby: 2.7.5
- Rails: 6.1.4.4
- graphql-ruby: 1.12.16
- graphql-guard: 2.0.0
前提
今回は以下のような架空のアプリケーション開発について考えてみたいと思います。
- ユーザ向け、販売者向けにSPAを提供する
- ユーザはユーザ向けSPA上で商品を閲覧する
- 本当は商品購入などもできるべきだと思いますが、シンプルにするため割愛
- 販売者は販売者向けSPA上で商品を登録、編集、削除する
- 販売者用管理画面のようなイメージ
- ユーザはユーザ向けSPA上で商品を閲覧する
- 両方のSPAから利用する、認可必須のGraphQL API (Rails)を用意する
- OAuth2.0のAuthorization Code Flowに則った形式でアクセストークンを取得する
- アクセストークンのPayloadに
permissions
が含まれる permissions
の中身によってAPIのQueryやMutation、アクセス可能なTypeのフィールドを制限したいpermissions
にはuser
、seller
のような値が入っているものと仮定する- 拡張性を考え、配列で渡ってくるものと仮定する
- アクセス制限はできればTypeやFieldごとに一つ一つ書いていく感じではなく、一元管理したい
- アクセス制限 = 例えば「ユーザは商品の登録、編集などはできないし、販売者はユーザのプロフィール登録、編集はできない」といったような設定
- 許可されていないリクエストをしたら
UNAUTHORIZED
的なエラーを含んだレスポンスを返却したい
スキーマのイメージはこんな感じです。
Railsでgraphql-rubyを利用する際、ジェネレータでGraphqlController
を生成してそれを拡張していくことが多いと思うのですが、SPAから渡ってきたアクセストークンをparseしてトークン所有者 (current_resource_owner
)やPermission情報 (permissions
)を以下のように渡すような状況をイメージしていただければと思います。
# frozen_string_literal: true module MyAuthModule def current_resource_owner # ResourceOwnerをpayloadから見つけるメソッドが定義されている想定 @current_resource_owner ||= ResourceOwner.find_by_token_payload(auth_payload) end def permissions @permissions ||= auth_payload&.permissions end private def auth_payload return @auth_payload if @auth_payload.present? http_token = request.headers['Authorization'].split(' ').last payload = JsonWebToken.verify(http_token) ... # do something @auth_payload = ... end end
# frozen_string_literal: true class MyApiController < ApplicationController include MyAuthModule end
# frozen_string_literal: true class GraphqlController < MyApiController def execute variables = prepare_variables(params[:variables]) query = params[:query] operation_name = params[:operationName] context = { # Query context goes here, for example: current_resource_owner: current_resource_owner, permissions: permissions, } result = MySchema.execute(query, variables: variables, context: context, operation_name: operation_name) render json: result rescue StandardError => e raise e unless Rails.env.development? handle_error_in_development e end private # Handle variables in form data, JSON body, or a blank value def prepare_variables(variables_param) case variables_param when String if variables_param.present? JSON.parse(variables_param) || {} else {} end when Hash variables_param when ActionController::Parameters variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. when nil {} else raise ArgumentError, "Unexpected parameter: #{variables_param}" end end def handle_error_in_development(err) logger.error err.message logger.error err.backtrace.join("\n") render json: { errors: [{ message: err.message, backtrace: err.backtrace }], data: {} }, status: :internal_server_error end end
では上記の前提のもと、graphql-guardを利用して良い感じにアクセス制限を実現する方法を考えていきましょう。
graphql-guardとは
まず「graphql-guardとは何ぞや」という話なのですが、GitHubのAboutに
Simple authorization gem for GraphQL.
と書かれているようにシンプルにアクセス制限を実装できるGemです。graphql-rubyと一緒に使います。
実際にgraphql-guardの実装を雑に読んでみると、graphql-rubyのTracing機能を利用しているようです。execute_field
イベントを捕捉して、type.respond_to?(:type_class)
がtrue
の時に権限チェックを行っているようですね。
また、利用したことがないので今回は触れないですが、CanCanCanやPunditのインテグレーションサポートもあるようです。
graphql-ruby単体でもAuthorizationに記載されているような機能は提供されているのですが、Policy Objectで書かれているようにアクセス権限の設定を一元管理できそうな雰囲気が特に良いなと思いました。
それでは実際にやっていきます。
graphql-guardを実際に使ってみる
先述したようなGraphqlController
があり、context
にpermissions
が渡ってくる想定で、graphql-guardの利用に必要な、以下のクラスの実装を考えていきます。
MySchema::Permission
- 各Permissionのアクセス権限の設定を記述するクラス
- どのPermissionがどのQueryやMutationを利用できるのか
- どのPermissionがどのTypeのどのFieldにアクセスできるのか
- 各Permissionのアクセス権限の設定を記述するクラス
MySchema::Policy
context
で渡ってくるpermissions
と上記のPermissionをもとにguardする処理を担当- guardした際のエラーハンドリングも担当
MySchema
- GraphQLのスキーマクラス
MySchema::Permission
直近の私のプロジェクトで積極的にactive_hashを利用していたので、今回はactive_hashを利用したサンプル実装を掲載します。普通のHashで実装しても問題はないと思います。
Enumのオブジェクトはこんな感じの想定です。
# frozen_string_literal: true module Enum class Object < ActiveHash::Base include ActiveHash::Enum field :id field :slug field :name end end
そしてEnumのオブジェクトを継承してPermissionのクラスを定義してみます。全フィールドを許可したい場合と一部のフィールドのみを許可したい場合などを考慮して以下のような実装を考えてみました。
各Permissionに対してアクセスできるQuery、 Mutation、Typeをホワイトリスト形式で許可していきます。QueryやMutationの実装は名前で雰囲気だけ感じ取っていただければと🙏
# frozen_string_literal: true # そういうQueryやMutation、Typeがあるんだなぁという強い想像力を働かせて読んでほしいです class MySchema class Permission < ::Enum::Object enum_accessor :slug self.data = [ { id: 1, slug: :user, name: 'user', authorized_fields: { Types::QueryType => %i[ currentResourceOwner products ], Types::MutationType => %i[ createUserProfile updateUserProfile ], Types::ResourceOwner => %i[ id email ], Types::User => %i[ id profile ], Types::User::Profile => %i[ firstName lastName ], Types::Product => %i[ id name price ], }, }, { id: 2, slug: :seller, name: 'seller', authorized_fields: { Types::QueryType => %i[ currentResourceOwner products product ], Types::MutationType => %i[ createProduct updateProduct deleteProduct ], Types::ResourceOwner => %i[ id email ], Types::Seller => %i[ id name ], Types::Product => %i[ id name price ], }, }, ] def authorized?(type, field) case authorized_fields[type] when Array # リストに含まれるフィールドのみ許可 authorized_fields[type].include?(field) when true # 全フィールド許可 true else # 全フィールド禁止 false end end end end
MySchema::Policy
ここで地味にMySchema::Permission#authorized?
が活きてきます。context
に渡ってきたPermissionを確認して、authorized?
かどうかを見てアクセス制限をやっていきます。
MySchema::Policy#not_authorized_handler
はError handlingで求められている not_authorized
メソッドで利用します (といってもエラーをraiseするだけですが)。
あとはintrospection
の場合はアクセス制限したくないので、その配慮を入れていたりします。
# frozen_string_literal: true class MySchema class UnauthorizedError < StandardError; end class Policy class << self def guard(type, field) return -> (_obj, _args, _ctx) { true } if type.introspection? lambda { |_obj, _args, ctx| raise MySchema::UnauthorizedError unless ctx.current_resource_owner permissions = MySchema::Permission.where(name: ctx.permissions) permissions.any? { |permission| permission.authorized?(type, field) } } end def not_authorized_handler(type, field) raise GraphQL::Guard::NotAuthorizedError, "Not authorized to access: #{type}.#{field}" end end end end
MySchema
最後に上で定義したクラスをスキーマで利用するよう設定すれば完了です!
許可されないリクエストをした場合はたぶん "message": "Not authorized to access: UserProfile.lastName"
のようなエラーが返るようになると思います。
# frozen_string_literal: true class MySchema UNAUTHORIZED = 'UNAUTHORIZED' FORBIDDEN = 'FORBIDDEN' mutation(Types::MutationType) query(Types::QueryType) use GraphQL::Guard.new( policy_object: MySchema::Policy, not_authorized: -> (type, field) { MySchema::Policy.not_authorized_handler(type, field) }, ) rescue_from MySchema::UnauthorizedError do |_err, _obj, _args, _ctx, _field| raise GraphQL::ExecutionError.new('Unauthorized', extensions: { code: UNAUTHORIZED }) end rescue_from GraphQL::Guard::NotAuthorizedError do |err, _obj, _args, _ctx, _field| raise GraphQL::ExecutionError.new(err.message, extensions: { code: FORBIDDEN }) end end
まとめ
今回はgraphql-guardを使って、アクセス制限を一元管理する実装例を紹介してみました。もちろんこれで全人類が幸せになるとは言い難いですが、シンプルなアクセス制限を実装する際の参考になれば幸いです。
Speeeでは一緒にサービス開発を推進してくれる仲間を大募集しています!
もしSpeeeに興味を持っていただいた方は以下で社内メンバーのカジュアル面談を公開しているので、お気軽にご連絡ください💁
Speeeでは様々なポジションで募集中なので、「どんなポジションがあるの?」と気になってくれてた方は、こちらチェックしてみてください!もちろんオープンポジション的に上記に限らず積極採用中です!!!