🦔

GraphQL × CanCanCan: fieldレベル認可の実装

に公開
2022年に取り組んだ内容です。サンプルコードに変更したり、当時のことを思い出して書いているので、抜けや漏れ等があるかもしれません。また、分かりづらい部分もあると思います。

複雑な権限要件を持つアプリケーションでは、データへのアクセスを細かく制御することが不可欠です。特にGraphQLを採用している場合、クライアント主導のデータ取得という柔軟性がもたらす新たな認可の課題に直面します。本記事では、Ruby on RailsアプリケーションでCanCanCanとGraphQLを組み合わせたfieldレベルの認可実装について、実践的なアプローチを解説します。

目次

  1. GraphQLにおける認可の課題
  2. CanCanCanによるロールベース認可の構成
  3. fieldレベル認可の実装
  4. エラーハンドリングとユーザー体験
  5. まとめと実践的なヒント

GraphQLにおける認可の課題

RESTful APIと異なり、GraphQLではクライアントが必要なデータを正確に指定できる柔軟性が大きな特徴です。しかし、この柔軟性は認可の観点から新たな課題をもたらします:

主な課題

  1. 階層構造による権限の連鎖 - GraphQLのネストされたリゾルバでは、親オブジェクトにアクセスできれば、その子孫fieldにも暗黙的にアクセスできてしまいます。
  2. field単位での制御の必要性 - ビジネス要件として、同一オブジェクト内の特定fieldのみ特定ロールに制限したいケースが頻出します。
  3. 認証と認可の明確な区別 - システム全体で一貫したアクセス制御ポリシーを維持するために、認証と認可を適切に分離する必要があります。
    • 当時の課題としては、新しい機能が認証情報がないユーザーへ情報提供だったので、セキュリティの課題があった。

例えば、商品情報(Product)のデータ構造において、管理者にはコスト情報を表示する一方、一般ユーザーには見せたくないといった要件があります:

query {
  product(id: "123") {
    name
    description
    # 管理者にのみ表示したいfield
    supplierCost
    profitMargin {
      percentage
      value
    }
  }
}

これはGraphQLの階層的なデータモデルでは特に難しい問題で、単純にモデルレベルの認可だけでは解決できません。

CanCanCanによるロールベース認可の構成

CanCanCanの基本思想

CanCanCanは、Railsアプリケーションにおいて最も広く使われている認可ライブラリの一つです。その基本思想はリソースベースの認可にあります。ユーザーが「何ができるか」ではなく、「どのリソースに対して何ができるか」という視点でアクセス制御を行います。

この思想は以下の形式で表現されます:

can :action, Subject

ここで:

  • :action:read, :create, :update, :destroy などの操作を表します
  • Subject はモデルクラスや特定のインスタンスを表します

例えば、「ユーザーは投稿を読むことができる」という権限は次のように定義します:

can :read, Post

さらに条件を追加することもできます:

can :update, Post, author_id: user.id

これは「ユーザーは自分が作成した投稿のみ更新できる」ことを意味します。このリソースベースの考え方は、RESTful APIと非常に相性が良く、モデル中心の認可ロジックを構築できます。

GraphQLに対応する場合は、この思想を拡張してfieldレベルの認可に適用します。つまり「どのリソースのどのfieldに対して何ができるか」という考え方になります。

GraphQL-Proとの統合

GraphQL Ruby Proを使用する場合、CanCanCanとの統合機能が公式にサポートされています。これにより、既存のCanCanCanの認可ロジックをGraphQLに簡単に適用できます。

ただし、本記事で説明するフィールドレベルの細かい制御は、追加の拡張が必要になります。

GraphQL-Pro がなくても、この記事で説明する方法でCanCanCanとGraphQLを統合できますが、Pro版を使用すると基本的な統合がより簡単になります。

まず、複数のユーザーロールに対応するために、CanCanCanの認可ロジックを整理する方法を見ていきましょう。

モジュールによる能力定義の分割

認可ロジックを管理しやすくするために、ロールごとに別々のモジュールに分割することが効果的です:

# app/models/ability.rb
class Ability
  include CanCan::Ability
  include Abilities::AdminAbility
  include Abilities::ManagerAbility
  include Abilities::StaffAbility
  include Abilities::RegularUserAbility
  include Abilities::GuestAbility

  attr_reader :user, :session

  def initialize(user, session)
    @user = user
    @session = session

    unless @user
      guest_ability
      return
    end

    case user.role
    when 'admin'
      admin_ability
    when 'manager'
      manager_ability
    when 'staff'
      staff_ability
    when 'regular'
      regular_user_ability
    else
      guest_ability
    end
  end
end

このように分割することで、各ロールの認可ロジックをそれぞれ独立して管理でき、コードの可読性と保守性が向上します。特に注目すべきは、未認証ユーザーにもguest_abilityとして明示的な権限セットを定義している点です。

各Abilityモジュールの実装例

それぞれのロールに対する認可ロジックは、専用のモジュールに定義します:

# app/models/abilities/staff_ability.rb
module Abilities
  module StaffAbility
    def staff_ability
      # モデルレベルでの基本認可
      can :read, Product
      # その他のリソースに対する権限...
    end
  end
end

# app/models/abilities/admin_ability.rb
module Abilities
  module AdminAbility
    def admin_ability
      can :read, Product
      # 管理者のみアクセス可能
      can :read, ::ProductType::AdminOnly # サンプルコードなので命名は適当です。
      # その他のリソースに対する権限...
    end
  end
end

この方法により、ロールごとにフィールドレベルの権限を詳細に定義できます。can :read, CustomNameの形式でフィールド固有の権限を指定しています。

CanCanCanを採用した理由

私たちのプロジェクトにおいて、GraphQL-Rubyが提供するField Visibility機能も検討しました。この機能は以下のように実装できます:

# GraphQL-Rubyの公式ドキュメントに基づく実装例
class Types::BaseField < GraphQL::Schema::Field
  def visible?(context)
    super && context[:current_user].can_view?(self.name)
  end
end

しかし、以下の理由から最終的にCanCanCanを使った実装アプローチを選択しました:

  1. 既存の認可システムとの統一性: プロジェクト全体で既にCanCanCanを使用しており、RESTfulエンドポイントやコントローラーレベルでの認可ロジックが整備されていました。GraphQLでも同じ認可システムを使用することで、アプリケーション全体で一貫した権限管理が可能になります。

  2. 役割とポリシーの再利用: ユーザーロールやポリシーの定義が既にCanCanCanで実装されていたため、これらを再利用することで開発効率と保守性が向上しました。

  3. チームの習熟度: 開発チームはCanCanCanの使用経験が豊富であり、学習コストを最小限に抑えつつ、高度な認可システムを構築できました。

  4. 統合された監視とエラー報告: 既存のCanCanCanエラーハンドリングとSentryによる監視体制を活かし、認可問題の早期発見と対応が可能になりました。

Field Visibilityを使った方法も十分に機能しますが、私たちの場合はアプリケーション全体での認可の一貫性とコード再利用の観点から、CanCanCanを拡張する選択をしました。この決断により、GraphQLとRESTful APIの両方で同じ認可ロジックが適用され、メンテナンス性と一貫性が向上しました。

また、今回実装したCanCanCanとGraphQLの統合アプローチにより、将来的な要件変更にも柔軟に対応できる基盤が整いました。例えば、新しいユーザーロールやフィールドレベルのアクセス制御が必要になった場合でも、単一の場所(Abilityモジュール)で定義を追加するだけで済みます。

fieldレベル認可の実装

GraphQLスキーマにCanCanCanの認可チェックを統合するための核心部分を見ていきましょう。

BaseFieldクラスの拡張

認可ロジックをGraphQLのフィールド解決プロセスに組み込むには、GraphQL Ruby の BaseField クラスを拡張します:

# app/graphql/types/base_field.rb
module Types
  class BaseField < GraphQL::Schema::Field
    argument_class Types::BaseArgument

    # queryの全fieldに認可を通します
    #
    # @param subject [Class] CanCanCanの認可用に設定する
    #                        fieldの戻り値がHash(modelなし)やActiveRecord::Relation等の場合に設定
    # ex)
    # app/graphql/types/statistics_type.rb
    # field :value, String, null: false, subject: self

    def initialize(*args, subject: nil, load_association: false, **kwargs, &block)
      @subject = subject
      if load_association
        super(*args, resolver_method: :load_association, extras: [:graphql_name], **kwargs, &block)
      else
        super(*args, **kwargs, &block)
      end
    end

    def authorized?(obj, args, ctx)
      return true unless obj
      
      subject = @subject || obj.class # subjectの引数があればそれを優先的に認可にかけます。
      super && ctx[:current_ability].can?(:read, subject, ctx[:current_field].original_name)
    end
  end
end

この実装の重要なポイント:

  1. subjectパラメータ - フィールドごとに認可チェックの対象を明示的に指定できるようにしています。これは特に非ActiveRecordオブジェクトや合成オブジェクトを返すフィールドで役立ちます。
  2. フィールド名を活用した認可チェック - ctx[:current_field].original_nameを使って、現在処理中のフィールド名をCanCanCanに渡し、そのフィールドに対する権限をチェックします。
  3. キャッシュの使用 - パフォーマンスを向上させるために、同一リクエスト内で同じフィールドに対する権限チェックをキャッシュします。

コンテキスト管理の実装

フィールドレベルの認可には、現在のフィールド情報などのコンテキスト情報が必要です。これを追加するモジュールを実装します:

# app/graphql/additional_context.rb

module AdditionalContext
  extend ActiveSupport::Concern

  private

  def current_user
    context.fetch(:current_user)
  end

  def current_ability
    context.fetch(:current_ability)
  end

  ...他の実装
  
  # 下記メソッドは任意の箇所で認可を実行する場合に使います。
  # Resolver,Mutationの中の認可として使われることが多いです
  # ↓のように使います
  # authorize!(:read, Product)
  # authorize!(:update, product)

  # @param action  [Symbol]
  # @param subject [Class] basically ApplicationRecord's child class
  def authorize!(action, subject)
    return true if current_ability.can?(action, subject)

    class_name = if subject.is_a?(Class)
                   subject.name
                 else
                   subject.class.name
                 end

    access_denied_error = ::CanCan::AccessDenied.new("#{current_user.role}に対して#{class_name}#{action}の操作は許可されていません", action, subject)
    Sentry.capture_exception(access_denied_error)
    raise access_denied_error
  end
end
authorize!(action, subject)のエラー通知は下記のようになります。

sentry

BaseObjectクラスとのインテグレーション

次に、この拡張したフィールドクラスをGraphQLのオブジェクト型で使用するよう設定します:

# app/graphql/types/base_object.rb
module Types
  class BaseObject < GraphQL::Schema::Object
    include ::AdditionalContext
    field_class Types::BaseField

    ...
  end
end

実際のタイプ定義での使用例

上記の基盤を使って、実際のGraphQLタイプ定義ではこのように認可が考慮されたフィールドを定義できます:

module Types
  class ProductType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    
    # コスト関連フィールド - 管理者のみアクセス可能(カスタム認可対象を持つフィールド)
    field :supplier_cost, Float, null: true, subject: ::ProductType::AdminOnly # サンプルコードなので命名は適当です。
  end
end

このように、各フィールドは自動的に認可チェックを受け、ユーザーが権限を持たないフィールドにはアクセスできなくなります。

※ Mutationのfieldは除外するような実装もしてますが、ここでは省略。

エラーハンドリングとユーザー体験

フィールドレベルの認可チェックが失敗した場合、適切なエラーを返すことが重要です。

未認可フィールドの処理

GraphQL Rubyでは、unauthorized_fieldメソッドをオーバーライドすることで、未認可フィールドへのアクセスに対する動作をカスタマイズできます:

# app/graphql/your_app_schema.rb
def self.unauthorized_field(error)  
  role = error.context[:current_user].role  
  class_name = error.context[:current_object].object.class  
  access_denied_error = CanCan::AccessDenied.new("#{role}に対して#{class_name}の操作は許可されていません", nil, error.context[:current_field])  
  
  Sentry.capture_exception(access_denied_error)  
  raise access_denied_error  
end

この実装では、GraphQLのエラー形式に沿った詳細情報をクライアントに提供しています。これにより、フロントエンドで適切なエラーハンドリングが可能になります。ただし、本番環境ではセキュリティ上の理由からエラーメッセージを簡略化することも検討すべきでしょう。

全fieldに対して認可を設定しているので、下記のように認可漏れや不正なアクセスがないか監視できるようにしています。

sentry

まとめと実践的なヒント

GraphQLとCanCanCanを組み合わせたフィールドレベルの認可実装は、複雑な権限要件を持つアプリケーションに大きな柔軟性をもたらします。以下に、実践で役立つヒントをまとめます:

ベストプラクティス

  1. 認証と認可を明確に分離する - 認証状態も認可ルールとして表現することで、アクセス制御ロジックを一元管理できます。
  2. モジュール化と再利用 - ロール別にAbilityモジュールを分割し、共通パターンを抽出することで、メンテナンス性を向上させます。
  3. 明示的な権限定義 - デフォルトは「拒否」として、必要な権限を明示的に許可する方針を取ります。
  4. フィールド名の統一 - GraphQLのフィールド名とモデルの属性名を一致させると、権限定義が直感的になります。
  5. 問題発生時のデバッグを容易にする - フィールドレベルの認可に関する問題が発生した場合、適切なログ記録やエラーメッセージが役立ちます。

注意点

  1. 権限爆発の回避 - 過度に細かな権限定義はメンテナンスの負担になります。ビジネス要件に基づいて適切な粒度を選びましょう。
  2. クライアント体験の考慮 - 認可エラーはクライアントにとって理解しやすい形で伝える必要があります。エラー表示のUXにも配慮しましょう。
  3. セキュリティ意識 - エラーメッセージには内部情報を含めないよう注意し、認可システムの詳細が外部に漏れないようにします。

フィールドレベルの認可は、GraphQLの柔軟性を活かしながら適切なセキュリティを確保するための重要な要素です。この記事で紹介したパターンを出発点として、あなたのプロジェクトに最適なアプローチを見つけてください。


この記事を通して、GraphQLにおけるフィールドレベルの認可実装について、基本的な概念から実践的なパターン、そして進化の過程までを解説しました。GraphQL-Proを使用すればCanCanCanとの基本的な統合はさらに簡単になりますが、本記事で紹介した手法を応用することで、より細かなフィールドレベルの制御が可能になります。これらの知識が、あなたのGraphQL APIにおける堅牢なアクセス制御実装の一助となれば幸いです。

株式会社Grooves

Discussion