はじめに

この記事は 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文チェックするのは多分破綻するので何かしら定義するか、ライブラリを使うのをオススメします

gophers by Renee French CC BY 3.0