🚀

Rails GraphQL API に権限管理を導入する (後編)

2023/08/10に公開

こんにちは。SHE株式会社エンジニアのおはるです🐱

📝 この記事について

SHEでは、Rails GraphQL API の権限管理の仕組みとして directives を導入することになりました。
導入にあたりチームでとても良いディスカッションができたため、せっかくなので意思決定までの過程や実際の導入例を記事にしたいと思います。
検討した内容をまとめた前編と、実際の導入事例をまとめた後編の2本立てとなり、この記事は後編にあたります。
▼前編はこちら
https://zenn.dev/she_techblog/articles/eee77b32d18c30

前提: 技術スタック

  • バックエンド
    • Ruby
    • Ruby on Rails
    • graphql-ruby
  • フロントエンド
    • TypeScript
    • React

🚀 採用した内容

前編で記述した通り、弊社で行いたい権限管理のレベルと上記の案を比較検討し、最終的には権限管理方法として directives を導入することにしました🎉

理由

  • ①現状、SHE では表層での権限管理のみ行えればOK

    • どんな権限管理ができればOK? での結論をふまえると、新しく導入する権限管理では graphql の query, mutation, field レベル でのチェックを行えれば充分だと判断しました
    • 今後下層でより複雑な権限管理を行いたくなった場合は、 pundit / cancancan などを導入して後から対応することもできそう(権限ロジック全部作り直し!とかにはならなさそう)なので、今は表層での権限管理に対応している directives を採用しても問題なさそうなことも決め手でした
  • ②導入のしやすさ

    • directives は graphql-ruby の公式ドキュメントでも紹介されている手法です。こちらの通りにセットアップすれば導入が可能なため、導入コストが低いことも魅力でした
    • また、GraphQL スキーマ を見るだけで、どの条件の人がその query や mutation を実行できるのかが分かるため、SHE としてやりたいことを全て達成できるのは、 directives だと判断しました
    # 例えば users query は employee というスキルを持つユーザーが実行できるとしたとき、こんな感じで GraphQL スキーマに反映されます
    
    type Quer { 
       users: [User!]! @auth(skill: "employee")
    }
    
    • フロントエンド的にもバックエンドの詳細な実装を見にいく必要がなくなるというメリットがありますね!

導入にあたり工夫した点

  • 前提

    • アプリケーション全体では、「ユーザー」と「ユーザーの持つスキル」をベースに権限管理を行なっています
    • 1人のユーザーは複数のスキルを持つ可能性があり、1つのアクションも複数のスキルが実行権限を持っています
  • 課題感

    • 公式ドキュメントに沿って素直にスキルベースで権限管理を行おうとすると以下のようになります

      # app/graphql/directives/permission.rb
      class Directives::Permission < GraphQL::Schema::Directive
        argument :skill, [String]
        locations FIELD_DEFINITION, OBJECT
      end
      
      
      # app/graphql/types/query_type.rb
      module Types
        class QueryType < Types::BaseObject
          field :users, [User!]!, null: false, directives: { Directives::Permission => { skill: ["employee"] } }
        end
      end
      
    • この実装でもやりたいことを実現できなくはないですが、スキルが増えるたびにそのスキルに対応する query や mutation などに追記が必要になります(ちょっとめんどくさいですね🙃)

    • スキルへの依存度が高く、もし仮にスキルでの権限管理を廃止する場合に影響範囲が大きくなるのも気になります

  • 上記の課題を踏まえ工夫した点

    • 前提でも記載した通り、1つのアクション(作成や更新)を複数のスキルが実行できる状態となっていたため、アクションごとにスキルをまとめて抽象化した permission という概念を導入することにしました(詳細は実際の導入イメージにて)
      • permission = read_hogeupdate_fuga のように「何をする権限」かを表しているもの

🍀 実際の導入イメージ

  • 例えば、以下のような要件で Reservations にアクセスできる query を作りたいと仮定して、サンプルコードを書いていきます🏃

    • 存在するスキルは「manager」「employee」「staff」の3つ
    • manager とemployee のスキルを持つ人は全ての Reservation にアクセスできる
    • staff のスキルを持つ人は自分の Reservation にのみアクセスできる
  • Permission の定義

    # app/models/permission_type.rb
    
    class PermissionType < ActiveHash::Base
      TYPE_READ_RESERVATIONS = "read_reservations".freeze
    
      self.data = [
        {
          id: TYPE_READ_RESERVATIONS,
          skill_type_names: [
            "manager",
            "employee",
          ],
        },
      ]
    end
    
  • バックエンド

    • directive の設定

      # app/graphql/directives/auth.rb
      
      class Directives::Auth < GraphQL::Schema::Directive
        argument :permission_type, String
        locations FIELD_DEFINITION, OBJECT
      
        def permission_type
          PermissionType.find(@arguments[:permission_type])
        end
      end
      
    • BaseFieldType の #authorized? をオーバーライド

      # app/graphql/types/base_field.rb
      
      module Types
        class BaseField < GraphQL::Schema::Field
      
          argument_class Types::BaseArgument
      
          # Override
          def authorized?(obj, args, context)
            return false unless super
      
            auth_directive = directives.find {|d| d.instance_of?(Directives::Auth) }
            return true if auth_directive.nil?
      
            current_user = context[:current_user]
            return false if current_user.nil?
      
            permission_type = auth_directive.permission_type
      
            # directive の permission_type で指定された skill_type が
            # ログインユーザーが持つ skill_types に含まれていたら true が返り query が実行できる
            # 含まれていなかったら false が返り エラーになる
            current_user.skill_types.exists?(name: permission_type.skill_type_names)
          end
        end
      end
      
    • query に権限設定

      # app/graphql/types/query_type.rb
      
      module Types
        class QueryType < Types::BaseObject
          # ...
          field :reservations, resolver: Resolvers::ReservationsResolver, directives: { Directives::Auth => { permission_type: PermissionType::TYPE_READ_RESERVATIONS } }
          # ...
        end
      end
      
    • フロント用に type の定義

      # app/graphql/types/permission_type_type.rb
      
      module Types
        class PermissionTypeType < Types::BaseEnum
          value "READ_RESERVATIONS", value: "read_reservations"
        end
      end
      
    • ログインユーザーが持つスキルの一覧を返す query を作成

       # app/graphql/types/query_type.rb
      
      module Types
        class QueryType < Types::BaseObject
          # ...
          field :reservations, resolver: Resolvers::ReservationsResolver, directives: { Directives::Auth => { permission_type: PermissionType::TYPE_READ_RESERVATIONS } }
          # permission_type_type の配列が返る 
          field :field :my_permissions, resolver: Resolvers::MyPermissionsResolver
          # ...
        end
      end
      
  • 生成される GraphQLスキーマ

    type Query {
      reservations: [Reservation!]! @auth(permissionType: "read_reservations")
    }
    
    enum PermissionType {
      READ_RESERVATIONS
    }
    
  • フロントエンド

    • 自動生成される型

      // app/javascript/packs/generated/graphql.ts
      
      export const PermissionType = {
        ReadReservations: 'READ_RESERVATIONS',
      } as const;
      
      export type PermissionType = typeof PermissionType[keyof typeof PermissionType];
      
    • 権限確認用の hook を作成

      // app/javascript/packs/commonv3/hooks/useMyPermissions/useMyPermissions.ts
      
      export const useMyPermissions = () => {
        const myPermissionsResult = await GraphQLClient.query(MyPermissionsDocument).toPromise();
        const myPermissions = myPermissionsResult.data?.myPermissions || [];
      
        const isAllowedAnyPermissions = (permissionTypes: PermissionType[]): boolean => {
          return myPermissions.some((permission) => permissionTypes.includes(permission));
        };
      
        return {
          /**
           * GraphQL の myPermissions query で取得したデータをもとに権限の有無を判定する関数
           * 与えられた permissionTypes のいずれかを所持しているかどうかを判定する
           */
          isAllowedAnyPermissions,
        };
      };
      
    • Reservations 一覧を表示しているページコンポーネント

      // サンプルコード
      
      const ReservationsPane = () => {
      	const { isAllowedAnyPermissions } = useMyPermissions();
        const isCreateAllowed = isAllowedAnyPermissions([PermissionType.ReadReservations]);
      
        if (isCreateAllowed === false) {
          return <UnauthorizedPage />
        }
      
        return {
          // something...
        }
      }
      

✨ 最後に

後編の内容は以上です。
最後に実際に導入してみて感じたことを記載します。少しでも参考になりましたら幸いです😊✨

  • コードの記述量が減りシンプルに書くことができるようになった
    • フロント側で管理していた部分は全て削除し、バックエンドのロジックに統一できた
    • PermissionType を定義すれば、query や mutation で宣言的に権限設定を行える
  • GraphQLスキーマでどのような権限が設定されているかを知ることができるため、フロント実装時にバックエンドの詳細ロジックまで見にいく手間がなくなった
  • 今後はログイン認証の仕組みも directive にのせていきたい
  • フロントエンドでも生成された PermissionType の型を使えるためより安心して開発ができるようになった
SHE Tech Blog

Discussion