はじめに
この記事は Go 4 Advent Calendar 2020 の 20 日目の記事です。
今回はgqlgenでField単位の権限チェックをする方法について書きたいと思います
(gqlgenの実装方法などについては過去記事を参照してください)
Field単位で権限チェックする方法
例えば、 記事(Article
) に 社内ユーザのみ見れるメモ(memo
) があったとします
# schema.graphql
type Article {
id: Int!
title: String!
body: String!
memo: String! # 社内ユーザのみ観覧可
}
memo
を社内ユーザのみ観覧可能にする方法について1つ1つ見ていきたいと思います
1. Directive
@inhouse
といったDirectiveをFieldに付ける方法です
# schema.graphql
directive @inhouse on FIELD_DEFINITION | OBJECT
type Article {
id: Int!
title: String!
body: String!
memo: String! @inhouse # 社内ユーザのみ観覧可
}
// directive_inhouse.go
func Inhouse(ctx context.Context, obj interface{}, next graphql.Resolver) (interface{}, error) {
// アクセスしてきたユーザ情報を取り戻す
// 実装は適当。ここでは middlewareでcontextに詰めておいたuserをGetAuthorizedUserという関数で取るようにした
user := GetAuthorizedUser(ctx)
if // アクセスしてきた user が @inhouse ついたfieldにアクセスできるか判定 {
return nil, fmt.Errorf("%w: you don't have a 'inhouse' permission to access this field", ErrForbidden)
}
// 権限があれば、次の処理へ
return next(ctx)
}
// main.go
c := Config{Resolvers: &Resolver{..})
// 定義した関数をセット
c.Directives.Inhouse = Inhouse
// Route
http.Handle("/query", mw.Handler(handler.GraphQL(
NewExecutableSchema(c),
..,
)))
メリット
- 宣言的でシンプル
- 持ち運びしやすい (schema.graphqlに情報があるので、言語実装を変えたとしても使える)
デメリット
- 複数fieldにまたがる複雑な権限チェックができない
- Field単位で毎回関数が呼ばれるため、権限チェックするためにデータ取得などしてる場合は、パフォーマンスが犠牲になる(RBACはいいけど、ABACは厳しい)
2. Typeを分ける
愚直にTypeを分けて2つ作る方法です
# schema.graphql
type Article {
id: Int!
title: String!
body: String!
}
type ArticleForInhouseUser {
id: Int!
title: String!
body: String!
memo: String! # 社内ユーザのみ観覧可
}
メリット
- Type/Resolverが権限ごとに完全に分かれるのでテストがしやすい
デメリット
- 権限チェックが必要なFieldごとに、同じ処理を書かないといけない(コピペコード)
3. CollectAllFields()
Resolver内でgraphql.CollectAllFields()
を使ってクライアントが要求してきた全Fieldを取得し、権限チェックを行う方法です
// resolver.go
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) {
// クライアントが要求してきたField一覧でぶん回す
for _, field := range graphql.CollectAllFields() {
if field == "memo" {
// memoを要求してきたので、権限チェック
}
}
// データ取得 ...
return getArticle(id)
}
メリット
- 複数fieldにまたがる複雑な権限チェックも可能(ABACなど)
- 権限チェックのためのデータ取得が伴う場合も、一度にチェックできるため最適化が可能(N+1回避など)
デメリット
- 手続き的でコードが読みづらくなりがち
- field名を生文字列でチェックするためtypoの可能性がある
- field名をrenameしたい場合、Resolver内のif文も忘れずに修正しないと事故る
※ graphql.CollectFieldsCtx()
でもできます
4. CollectFieldsCtx() & Directive
1と3の合わせ技です。
Directiveで定義したものをResolver内でチェックすることで、Resolver内にどのFieldのチェックが必要かの知識を持たずに済むようになってます。
// resolver.go
func (r *queryResolver) Article(ctx context.Context, id int) (*model.Article, error) {
// クライアントが要求してきたField一覧でぶん回す
for _, column := range graphql.CollectFieldsCtx(ctx, nil) {
for _, d := range column.Definition.Directives {
if d.Name == "inhouse" {
// @inhouseと定義されたfieldを要求してきたので、権限チェック
}
}
}
// データ取得 ...
return getArticle(id)
}
メリット
- 具体的なfield名を各Resolverが知らなくて良くなるので、仕様変更に強くなる
- 1と3に記載したメリット(上記参照)
デメリット
- Resolverは directive名を知る必要がある
- ただ、ctx さえ渡してやれば権限チェックロジックを util関数 としてくくり出せるので工夫の余地はある
- d.Name == “inhouse” の部分で依然生文字列チェックが残る (field名チェックよりまし)
5. 権限ごとにResolverを分ける
権限ごとにResolverを分けてしまうやり方です
# schema.graphql
type Article {
id: Int!
title: String!
body: String!
memo: Memo!
}
# 社内ユーザのみ観覧可
type Memo {
memo: String!
}
# gqlgen.yaml
Article:
fields:
memo:
resolver: true
// resolver.go
func (resolver) Article (ctx context.Context, id int) (*model.Article, error){
// memo も含めてデータ取得
// SELECT id, title, body, memo FROM articles WHERE id = ?
// memoも返してるように見えるが、クライアントが memo をリクエストしてなければ返えらない。
return article, nil
}
func (articleResolver) Memo (ctx context.Context, obj *model.Article) (string, error) {
// 権限チェック (社内ユーザかを見る)
// 問題なければ返す
return obj.memo
}
メリット
- 生文字列チェックしなくて良い
- 1Resolver - 1権限チェックで実装がシンプル。仕様変更に強い。
- SQLは計一本でパフォーマンス劣化がない
- クライアントから見ても、どのフィールドが権限が必要なのかがわかりやすい
デメリット
- 権限都合でレスポンスの形式を決めることになる
※ちなみに、ネストするのが気持ち悪い場合、gqlgen.yml
の設定で memo
だけを別resolverにしてしまえば同様のことができます。ただしこの例では、memo
だけを取りに行くSQLが必要になるので、SQLは計二本必要になります。
まとめ
採用する権限モデルに応じて使い分けるのがいいと思います
RBACなら①、ABACなら④や⑤あたりが妥当な選択になりそうです
まあそれでも、Graphqlで細かな権限チェックするのは複雑になりがちです
権限が複雑なアプリケーションを作る場合は、Graphqlでやるメリットデメリットを考慮し、RESTでやることを考えた方がいいケースもあるかもしれません。
余談: ポリシールール定義
この記事のスコープ外ですが、ポリシールールの定義をどうするかの問題もあります
私は自前実装したのですが、casbin といったライブラリも良さげ感ありますね
手続き的にif文チェックするのは多分破綻するので何かしら定義するか、ライブラリを使うのをオススメします