Rails GraphQL API に権限管理を導入する (後編)
こんにちは。SHE株式会社エンジニアのおはるです🐱
📝 この記事について
SHEでは、Rails GraphQL API の権限管理の仕組みとして directives を導入することになりました。
導入にあたりチームでとても良いディスカッションができたため、せっかくなので意思決定までの過程や実際の導入例を記事にしたいと思います。
検討した内容をまとめた前編と、実際の導入事例をまとめた後編の2本立てとなり、この記事は後編にあたります。
▼前編はこちら
前提: 技術スタック
- バックエンド
- 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_hoge
やupdate_fuga
のように「何をする権限」かを表しているもの
- permission =
- 前提でも記載した通り、1つのアクション(作成や更新)を複数のスキルが実行できる状態となっていたため、アクションごとにスキルをまとめて抽象化した
🍀 実際の導入イメージ
-
例えば、以下のような要件で 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 の型を使えるためより安心して開発ができるようになった
「SHElikes(シーライクス)」を運営するSHEの開発チームがお送りするテックブログです。私たちは社会的不均衡の解決を目指すインパクトスタートアップです。【エンジニア積極採用中】カジュアル面談、副業からのトライアル etc 承っております💪 採用情報 -> bit.ly/3XxywnD
Discussion