📌

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

2023/07/04に公開

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

📝 この記事について

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

前提: 技術スタック

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

🌱 背景

まずは、そもそもなぜ Rails GraphQL API に 権限管理を導入することになったのか?どんな課題感があったのか?について記載します。

現状

  • SHE のアプリケーション全体では、「ユーザー」と「ユーザーの持つスキル」をベースに以下のように権限管理を行なっていました
    • 1人のユーザーは複数のスキルを持つ可能性があり、1つのアクションも複数のスキルが実行権限を持っている状態
    • 行っていたこと
      • フロントエンド側にて、スキルに対して、閲覧可能なページと実行可能なアクションを定義する(以下イメージ)
      // 実行可能なアクション
      export enum PermissionKeys {
        UPDATE_USER = 'update_user',
        CREATE_RESERVATION = 'create_reservation'
      }
      
      // スキルと閲覧可能なページ・実行可能なアクションをマッピング
      const PERMISSION_SPECS: PermissionSpec[] = [
        {
          skillPattern: /manager|employee/,
          patterns: [
            '/admin/users',
            '/admin/reservations',
            PermissionKeys.UPDATE_USER,
            PermissionKeys.CREATE_RESERVATION,
          ],
        },
        {
          skillPattern: /staff/,
          patterns: [
            '/admin/reservations',
            PermissionKeys.CREATE_RESERVATION,
          ],
        },
      ]
      
      • 別途、ログインユーザーが持っているスキルの一覧を返す API を用意する
      • 権限管理を行いたいページやアクション単位で、ユーザーが持っているスキルとそのページの閲覧やアクションを許可されているスキルを比較し、表示を出し分ける

課題感

  • ①バックエンドの権限管理との整合性
    • 並行してバックエンドへの権限管理導入を検討していましたが、上記に記載した管理方法はフロントエンド内で完結しているもののため、整合性をどう保つかという課題がありました
      • 具体的にはバックエンドに閉じた形で権限管理を導入すると、バックエンドでは許可されていることがフロントエンドでは禁止されている(逆も然り)など、整合性の取れていない状態になる可能性がありました
    • → これを機に権限管理の軸となる情報を統一し、バックエンド / フロントエンド 問わず同じ内容で権限管理ができる仕組みを検討する必要がありました
  • ②複雑性
    • 権限の判定がアプリケーションロジックと分離されていないため、中身をすべて読まないとどのような制御になっているか分からない状態になっていました
    • また、フロントエンド側の関心ごととしては、ログインユーザーに該当ページの閲覧やアクションの実行権限が存在するか?ということだけで、どのスキルに何が許可されているか?までは意識しなくていいはずです。現状はフロントエンド側でどのスキルに何が許可されているか?という情報まで扱っていたため、複雑性が増していました
    • → できれば今使用しているフロントエンドの権限管理の仕組みは全て廃止し、バックエンドに寄せるとしてもコードの中身まで見に行かなくて良いようにしたい、という想いがありました(例えば GraphQL スキーマを見ればどんな権限が必要か分かるなど)

これらの課題感をふまえ、以下のような条件で新しい権限管理の仕組みを検討していくことになりました🏃

  • バックエンドとフロントエンドで共通して使用できる仕組み
  • フロントエンドで行なっている既存の仕組みは廃止する
  • コードの詳細まで確認せずとも権限管理の内容が分かるようにしたい
    • たとえば graphql-ruby は field ごとに authorized? の中で権限詳細を実装することも可能ですが、authorized? の中身を確認しに行くのではなく、GraphQL スキーマの段階でどのような権限設定がされているのか分かるのが理想

🤔 どんな権限管理ができればOK?

権限管理と一言で言ってもいろいろなやり方がありますし、何を採用するとベストかは時と場合によって変わりますよね💡
そのため、まず SHE ではレベルでの権限管理ができれば良いのか?をユースケースを洗い出して整理しました。

  • 権限管理をしたい具体的なユースケース

    • ①特定の query や mutation は 特定のスキルを持っている人のみ実行可能
    • ②特定の Object には特定のスキルを持っている人のみアクセス可能
    • ③hoges query は誰でも実行できるが、 その中の特定の argument の利用は 特定のスキルを持っている人のみ利用可能
    • ④特定の Object にはログインしていれば誰でもアクセス可能だが、その中の特定の field には 特定のスキルを持っている人のみアクセス可能
    • ⑤特定の Object(例えば Reservation など) 自体には誰でもアクセス可能だが、会員は自分の Reservation のみアクセス可能
    • などなど…
  • 結論

    • ①、④ のパターンに関しては何かしらの権限管理が必要そう

    • ②、③、⑤に関してはクエリのデザインを工夫すれば対応できそう

      • ②の例: User の情報には employee のスキルを持つ人のみアクセス可能にしたい場合
      # User の一覧、詳細用の query を個別に作り、query 単位で権限設定を行う
      
      type Query {
        users: [User!]!
        user(id: ID!): User
      } 
      
      • ③の例: User の情報には誰でもアクセスできるが、 email での絞り込みは employee のスキルを持つ人のみ可能にしたい場合
      # user query とは別に email で絞り込む用の query を作成し、後者に権限設定を行う
      
      type Query {
        user(id: ID!): User # こちらは誰でも利用可能
        userByEmail(email: String!): User # こちらに権限設定を行う
      } 
      
      • ⑤の例: Reservation には誰でもアクセス可能だが、ユーザーは自分の Reservation にのみアクセス可能にしたい場合
      # 自分の Reservation にアクセスする専用の query を作成する
      
      type Query {
        myReservations: [Reservations!]!
      }
      

🐳 検討した内容

次に、候補となる権限管理方法についてリサーチし、概要とメリット・デメリットを整理しました。

  • 案①: directives
    • 基本方針

    • メリット

      • 表層での管理になるため、下層での管理より記述・実装が簡単
      • 宣言的に書きやすい ⇒ クライアントからみて、「このアクション(API)を自分は実行できるのか?」を GraphQLスキーマを通して事前に判断できる
    • デメリット

      • 複雑なロジックの権限管理の場合、漏れが起きやすい
        • 例: ある query で権限チェックをしつつ、別の REST API やバッチ処理等で同じように権限チェックが必要、そちらのチェックが漏れる可能性がある
      • 表層だけでは対応できないようなパターンもある
        • A ⇒ B ⇒ (if hoge) C
        • C にたどり着いたときに権限がなくてひっかかる可能性があるとする
        • このロジックは下層に埋もれていて、表からは判断できない
      • Model 単位の制限はできない → 特定の Object(例えば Reservations)には管理者 or 特定のスキルを持っている人のみアクセス可能 → ただしこれはクエリデザインで一定解決できそう
      • Policy ベースの制限はできない → Reservation Object 自体には誰でもアクセス可能だが、シーメイトは自分の Reservation のみアクセス可能 → ただしこれはクエリデザインで一定解決できそう

  • 案②: Pundit

    • 基本方針

      • model ごとに Policy class を作成
      • リソースに対して誰が許可されるのかを定義する(モデル寄り)
      • Scope という概念を使うと、特定のユーザーがアクセスできるレコードを制限できる
      • 実装例
        • policy class

          
          class PostPolicy < ApplicationPolicy
             # Policy ファイルに Scope クラスを定義することで特定のユーザーがアクセスできるレコードを制限できる。
             class Scope
              def initialize(user, scope)
                @user  = user
                @scope = scope
              end
          
              def resolve
                if user.admin?
                  scope.all
                else
                  scope.where(published: true)
                end
              end
          
              private
          
                attr_reader :user, :scope
          
                def show?
                  user.admin? || user.staff? || user.cs?
                end
          
                def update?
                  user.admin?
                end
            end
          end
          
        • type

          class Types::PostType < Types::BaseObject
            def self.authorized?(object, context)
              # ユーザーの権限を pundit でチェックして NG ならこのコンテンツは取得できない
              super and PostPolicy.new(context[:current_user], object).show?
            end
          
            field :id, ID, null: false
          end
          
        • query の resolver

          class Resolvers::PostResolver < Resolvers::BaseResolver
            description "Get post by id"
          
            argument :id, ID, required: true
          
            type Types::PostType, null: false
          
            def resolve(id:)
              # policy_scope メソッドを使ってスコープを利用することができる。
              # ここでは取得のみ行う
              policy_scope(Post).find(id)
            end
          end
          
        • mutation

          class Mutations::UpdatePost < Mutations::BaseMutation
            field :post, Types::PostType, null: true
            field :errors, [Types::ErrorType], null: false
          
            argument :id, ID, required: true
          
            # ユーザーの権限を pundit でチェックして NG ならこのコンテンツは更新できない
            def authorized?(id:, **args)
              @post = policy_scope(Post).find(id)
              PostPolicy.new(context[:current_user], @post).update?
            end
          
            def resolve(id:)
              # something
            end
          end
          
    • メリット

      • model ごとに policy class を作成するので、 モデル数が多い場合でも policy ファイルはシンプルに保つことができる
    • デメリット

      • それぞれのPolicyファイルを確認しないと権限設定がわからないため、権限の全体が見通しづらい
      • query, mutation, argument, field レベルでの権限管理はできない

  • 案③: CanCanCan

    • 基本方針

      • 権限管理用の Ability class を作成(一箇所で管理)
      • ユーザに対して、どんなアクションを許可するかを定義する(コントローラ寄り)
      • 実装例
        • Ability class

          class Ability
            include CanCan::Ability
          
            # public や user は カラム or アソシエーション 名
            def initialize(user)
              # 全てのユーザーは public: true な post を読み取り可能
              can :read, Post, public: true
          
              return unless user.present?
              # ログイン済みのユーザーは 自分の post も読み取り可能
              can :read, Post, user: user
          
              return unless user.admin?
              # admin ユーザーは全ての post を読み取り可能  
              can :read, Post
              # admin ユーザーは全ての post の更新も可能  
              can :update, Post
            end
          end
          
        • query

          class Resolvers::PostResolver < Resolvers::BaseResolver
            description "Get post by id"
          
            argument :id, ID, required: true
          
            type Types::PostType, null: false
          
            def authorized?(id:, **args)
              @post = Post.find(id)
              # 権限がなければ例外が発生する
              authorize! :read, @post
            end
          
            def resolve(id:)
              post = Post.find(id)
            end
          end
          
        • mutation

          class Mutations::UpdatePost < Mutations::BaseMutation
            field :post, Types::PostType, null: true
            field :errors, [Types::ErrorType], null: false
          
            argument :id, ID, required: true
          
            def authorized?(id:, **args)
              @post = Post.find(id)
              # 権限がなければ例外が発生する
              authorize! :update, @post
            end
          
            def resolve(id:)
              # something
            end
          end
          
    • メリット

      • 権限の設定はすべてAbilityクラスで定義されているため、権限の全体が理解しやすい
    • デメリット

      • データモデルが多く複雑な権限体系の場合は大量の分岐処理が一つのクラスの中に記述されるため不向き
      • Model 単位で制御は難しいため、権限管理が複雑な場合も肥大化しやすい
      • query, mutation, argument, field レベルでの権限管理はできない
      • cancancan + GraphQL(not Pro)の知見があまりネットに落ちていない

  • 案④: GraphQL Pro の Pundit integration
    • 基本方針

      • ベースは pundit の基本方針と一緒
      • それに加え、Object, field, argument, mutation. resolver 単位での制御が可能
      • 実装例
        • Policy class

          class PostPolicy < ApplicationPolicy
             class Scope
              def initialize(user, scope)
                @user  = user
                @scope = scope
              end
          
              def resolve
                if user.admin?
                  scope.all
                else
                  scope.where(published: true)
                end
              end
          
              private
          
              attr_reader :user, :scope
          
              def signed_in
                current_user
              end
          
              def admin?
                user.admin?
              end
          
              def admin_or staff?
                user.admin? || user.staff?
              end 
          
              def admin_or_staff_or_cs?
                admin? || user.staff? || user.cs?
              end
            end
          end
          
        • type(object)

          class Types::Post < Types::BaseObject
            # PostPolicy#admin_or_staff_or_cs? をパスすればアクセスできる
            pundit_role :admin_or_staff_or_cs
            # ...
          end
          
        • field

          class Types::Post < Types::BaseObject
            pundit_role :signed_in
          
            # text はログインしていたら誰でもアクセス可能
            field :text, String,
            # memo は PostPolicy#admin? をパスしたユーザーのみアクセス可能
            field :memom, String, pundit_role: :admin
          end
          
        • argument

          class Resolvers::PostsResolver < Resolvers::BaseResolver
            description "Get posts by id"
          
            # 全てのユーザーは user_id を使用して filter が可能
            argument :user_id, ID, required: false
            # PostPolicy#admin? をパスしたユーザーのみ status を使用して filter が可能
            argument :status, ID, required: false, pundit_role: :admin
          
            type Types::PostType, null: false
          
            def resolve(id:)
              # something
            end
          end
          
        • query

          module Types
            class QueryType < Types::BaseObject
            # PostPolicy#admin_or_cs? をパスしたユーザーのみ以下 query の実行が可能
              field :post, resolver: Resolvers::PostResolver, pundit_role: :admin_or_cs
            end
          end
          
        • mutation

          • mutation レベルでの制御が可能
          • エラーハンドリングも可能
          class Mutations::Post < Mutations::BaseMutation
            # PostPolicy#admin? をパスしたら resolve が実行される
            pundit_role :admin
          
            field :post, Types::PostType, null: true
            field :errors, [Types::ErrorType], null: false
          
            # エラーハンドリング
            def unauthorized_by_pundit(owner, value)
              # Return errors as data:
              { errors: ["Missing required permission: #{owner.pundit_role}, can't access #{value.inspect}"] }
            end
          
            def resolve(id:)
              # something
            end
          end
          
    • メリット

      • 公式ドキュメントがあるので分かりやすい
      • Object, field, argument, mutation. resolver 単位で制御ができるので、より厳密な権限管理が可能
      • このスキルを持つ人はこのアクションを実行できる、ということが直感的に分かりやすい
    • デメリット

      • 有料である(Buy @ $900/year (11~12万))

  • 案⑤: GraphQL Pro の CanCan integration
    • 基本方針

      • ベースは CanCanCan の基本方針と一緒
      • それに加え、Object, field, argument, mutation. resolver 単位での制御が可能
      • 実装例
        • Ability class

          class Ability
            include CanCan::Ability
          
            # public や user は カラム or アソシエーション 名
            def initialize(user)
              # 全てのユーザーは public: true な post を読み取り可能
              can :read, Post, public: true
          
              return unless user.present?
              # ログイン済みのユーザーは 自分の post も読み取り可能
              can :read, Post, user: user
          
              return unless user.admin?
              # admin ユーザーは全ての post を読み取り可能  
              can :read, Post
              can :read_memo, Post
              can :admin, Post
              can :run_update_post_mutation, Post
            end
          end
          
        • type(object)

          class Types::Post < Types::BaseObject
            # Post に対して read 権限を持っているユーザーであればアクセス可能
            can_can_action(:read)
            # ...
          end
          
        • field

          class Types::Post < Types::BaseObject
            # text は誰でもアクセス可能
            field :text, String,
            # memo は Post に対して read_memo 権限を持っているユーザーであればアクセス可能
            field :memo, String, can_can_action: :read_memo
          end
          
        • argument

          class Resolvers::PostsResolver < Resolvers::BaseResolver
            description "Get posts by id"
          
            # 全てのユーザーは user_id を使用して filter が可能
            argument :user_id, ID, required: false
            # Post に対して admin 権限 を持っているユーザーのみ status を使用して filter が可能
            argument :status, ID, required: false	can_can_action: :admin
          
            type Types::PostType, null: false
          
            def resolve(id:)
              # something
            end
          end
          
        • query

          module Types
            class QueryType < Types::BaseObject
            # Post に対して admin 権限 を持っているユーザーのみ以下 query の実行が可能
              field :post, resolver: Resolvers::PostResolver, can_can_action: :admin
          end
          
        • mutation

          • mutation レベルでの制御が可能
          • エラーハンドリングも可能
          class Mutations::Post < Mutations::BaseMutation
            # Post に対して run_update_post_mutation 権限 を持っていたら resolve が実行される
            can_can_action :run_update_post_mutation
          
            field :post, Types::PostType, null: true
            field :errors, [Types::ErrorType], null: false
          
            # エラーハンドリング
            def unauthorized_by_can_can(owner, value)
              # Return errors as data:
              { errors: ["Missing required permission: #{owner.pundit_role}, can't access #{value.inspect}"] }
            end
          
            def resolve(id:)
              # something
            end
          end
          
    • メリット

      • 公式ドキュメントがあるので分かりやすい
      • Object, field, argument, mutation. resolver 単位で制御ができるので、より厳密な権限管理が可能
    • デメリット

      • 有料である(Buy @ $900/year (11~12万))

🎉 採用した内容

SHE で行いたい権限管理の内容と上記の案をチームで比較検討し、最終的には 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")
    }
    
    • フロントエンド的にもバックエンドの詳細な実装を見にいく必要がなくなるというメリットがありますね!

後編へつづく

前編の内容は以上です。
後編では directive 導入時の課題や、実際にどのように導入したのかについてまとめています。ぜひ後編もご覧ください😊✨
https://zenn.dev/she_techblog/articles/2fb7ede95982ea

SHE Tech Blog

Discussion