Rails GraphQL API に権限管理を導入する (前編)
こんにちは。SHE株式会社エンジニアのおはるです🐱
📝 この記事について
SHEでは、Rails GraphQL API の権限管理の仕組みとして directives を導入することになりました。
導入にあたりチームでとても良いディスカッションができたため、せっかくなので意思決定までの過程や実際の導入例を記事にしたいと思います。
検討した内容をまとめた前編と、実際の導入事例をまとめた後編の2本立てとなり、この記事は前編にあたります。
▼ 後編はこちら
前提: 技術スタック
- バックエンド
- 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
-
基本方針
-
https://graphql-ruby.org/type_definitions/directives.html#custom-schema-directives
-
実装イメージ
directive @auth(role: Role) on FIELD_DEFINITION | OBJECT type Query { bar: Bar! @auth(role: ADMIN) baz: Baz! @auth(role: KOUSHI_WITH_FUGAFUGA_SKILL) } enum Role { ADMIN KOUSHI_WITH_FUGAFUGA_SKILL # ... }
-
-
メリット
- 表層での管理になるため、下層での管理より記述・実装が簡単
- 宣言的に書きやすい ⇒ クライアントからみて、「このアクション(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 導入時の課題や、実際にどのように導入したのかについてまとめています。ぜひ後編もご覧ください😊✨
「SHElikes(シーライクス)」を運営するSHEの開発チームがお送りするテックブログです。私たちは社会的不均衡の解決を目指すインパクトスタートアップです。【エンジニア積極採用中】カジュアル面談、副業からのトライアル etc 承っております💪 採用情報 -> bit.ly/3XxywnD
Discussion