Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

graphql-guardでGraphQL APIのアクセス制限をシンプルに実現する方法を考えてみた

※この記事は、Speee Advent Calendar23日目の記事です。 昨日の記事はこちら

tech.speee.jp


どうも、最近は専らgraphql-rubyと戯れている石井です。

みなさん、graphql-rubyを使っていて「このTypeのこのフィールドはAdminユーザにしか見せたくないんです!」といったようにアクセス制限したくなる時が3日に1回ぐらいはやって来るのではないでしょうか。

今回はRailsとgraphql-rubyでAPIを作る際にgraphql-guardを使ってシンプルなアクセス制限を実現する方法をソースコードを織り交ぜつつ紹介してみたいと思います。


※本記事で記載するコードは全て実装の雰囲気を伝えることが目的のサンプルコードなのでご了承ください。

f:id:d_animal141:20211217183552p:plain

想定する環境

この記事では下記のバージョンの言語、ライブラリの利用を想定しています。

  • Ruby: 2.7.5
  • Rails: 6.1.4.4
  • graphql-ruby: 1.12.16
  • graphql-guard: 2.0.0

前提

今回は以下のような架空のアプリケーション開発について考えてみたいと思います。

  • ユーザ向け、販売者向けにSPAを提供する
    • ユーザはユーザ向けSPA上で商品を閲覧する
      • 本当は商品購入などもできるべきだと思いますが、シンプルにするため割愛
    • 販売者は販売者向けSPA上で商品を登録、編集、削除する
      • 販売者用管理画面のようなイメージ
  • 両方のSPAから利用する、認可必須のGraphQL API (Rails)を用意する
    • OAuth2.0のAuthorization Code Flowに則った形式でアクセストークンを取得する
    • アクセストークンのPayloadにpermissionsが含まれる
    • permissionsの中身によってAPIのQueryやMutation、アクセス可能なTypeのフィールドを制限したい
      • permissionsにはusersellerのような値が入っているものと仮定する
      • 拡張性を考え、配列で渡ってくるものと仮定する
  • アクセス制限はできればTypeやFieldごとに一つ一つ書いていく感じではなく、一元管理したい
    • アクセス制限 = 例えば「ユーザは商品の登録、編集などはできないし、販売者はユーザのプロフィール登録、編集はできない」といったような設定
  • 許可されていないリクエストをしたらUNAUTHORIZED的なエラーを含んだレスポンスを返却したい

スキーマのイメージはこんな感じです。

f:id:d_animal141:20211217181828p:plain

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の時に権限チェックを行っているようですね。

また、利用したことがないので今回は触れないですが、CanCanCanPunditのインテグレーションサポートもあるようです。


graphql-ruby単体でもAuthorizationに記載されているような機能は提供されているのですが、Policy Objectで書かれているようにアクセス権限の設定を一元管理できそうな雰囲気が特に良いなと思いました。


それでは実際にやっていきます。

graphql-guardを実際に使ってみる

先述したようなGraphqlControllerがあり、contextpermissionsが渡ってくる想定で、graphql-guardの利用に必要な、以下のクラスの実装を考えていきます。

  • MySchema::Permission
    • 各Permissionのアクセス権限の設定を記述するクラス
      • どのPermissionがどのQueryやMutationを利用できるのか
      • どのPermissionがどのTypeのどのFieldにアクセスできるのか
  • 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_handlerError 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に興味を持っていただいた方は以下で社内メンバーのカジュアル面談を公開しているので、お気軽にご連絡ください💁

tech.speee.jp

Speeeでは様々なポジションで募集中なので、「どんなポジションがあるの?」と気になってくれてた方は、こちらチェックしてみてください!もちろんオープンポジション的に上記に限らず積極採用中です!!!