💯

graphql serverの認可制御とテストが個人的にキレイにできた話

2022/11/14に公開

まえがき

最近、仕事ではFEもBEもTypescriptで書かれているシステムの改修を主に行っています。
BEはGraphQLなのですがHasuraとかを使っていないピュアなapollo-server
なので、認可制御どうしようかとなっていましたが、納得いく形に収まったのでメンバーへの共有を兼ねてまとめることにしました。

色々探して見つけたgraphql-shield

初めの方は、apolloのドキュメントに書かれているようにResolverの各Query、Mutationの中でContextを検証することを検討しました。

Resolverでの認可方法例
const queryResolver: gql.QueryResolvers<ResolverContext> = {
	employees: (parent, args, context) => {
		// contextの評価
		if (!context.employee) return null;
		return ['bob', 'jake'];
	};
	departments: (parent, args, context) => {
		if (!context.department) return null;
	};
	...
};

https://www.apollographql.com/docs/apollo-server/security/authentication/#in-resolvers

これぐらいの数のQuery、MutationならResolverに書くでも良いと思うのですが、実務で10、20あるQuery、Mutationに書いていくのは大変なのと、簡単に実装できる反面、各Query、Mutationの認可を一目で確認できないと思い、他の方法を考えることにしました。

このドキュメントには、他にもモデル内での検証やGraphqlのカスタムディレクティブを使った方法などが紹介されています。

カスタムディレクティブを使う事も検討しましたが、今回FEとBEがモノリポでどちもらTypescriptで書かれているため、FEで定義しているEnumと同じようなEnumをBEやGraphQL内で定義して(二重定義)使用したくなかったので、この方法もやめました。
(下記例のenum部分)

カスタムディレクティブの例
directive @auth(requires: Role = ADMIN) on OBJECT | FIELD_DEFINITION

enum Role {
  ADMIN
  REVIEWER
  USER
}

type User @auth(requires: USER) {
  name: String
  banned: Boolean @auth(requires: ADMIN)
  canPost: Boolean @auth(requires: REVIEWER)
}

「他に方法無いか」と調べて見つけたのが、次のスクラップでした。
https://zenn.dev/ynakamura/scraps/7567b47b105bf2

このスクラップで初めてgraphql-shieldというものの存在を知りました。The Guildと呼ばれるオープンソース開発グループによって提供されており、
このgraphql-shieldはapollo-serverに対してMiddlewareの形で認可レイヤーを提供することができます。
ここには例しか載せないですが、graphql-shieldによって実際かなりシンプルに認可制御を書くことができました。

graphql-shiledの例
import { shield, rule, and, or } from 'graphql-shield'

// Booleanを返す独自のルール作成
// middlewareなので、ruleが受け取るparent, args, ctxなどはresolverが受け取るものと同じです
const isAuthenticated = rule({ cache: 'contextual' })(async (parent, args, ctx, info) => {
  return ctx.user !== null
})
 
const isAdmin = rule({ cache: 'contextual' })(async (parent, args, ctx, info) => {
  return ctx.user.role === 'admin'
})
 
const isEditor = rule({ cache: 'contextual' })(async (parent, args, ctx, info) => {
  return ctx.user.role === 'editor'
})
 
// 実際に認可制御する部分
const permissions = shield({
  Query: {
    frontPage: not(isAuthenticated),
    fruits: and(isAuthenticated, or(isAdmin, isEditor)),
    customers: and(isAuthenticated, isAdmin),
  },
  Mutation: {
    addFruitToBasket: isAuthenticated,
  },
  Fruit: isAuthenticated,
  Customer: isAdmin,
})
apollo-serverの設定例
import { makeExecutableSchema } from '@graphql-tools/schema';
import { applyMiddleware } from 'graphql-middleware';

const schema = makeExecutableSchema({typeDefs, resolvers});
const schemaWithMiddleware = applyMiddleware(schema, permissions);

const apolloServer = new ApolloServer({
  schema: schemaWithMiddleware,
  context,
});

テストに関して

次に問題になったのはこのMiddlewareで定義したいい感じの認可制御のテストをどうやって書くかです。
調べていく中で、テストに関してもgraphql-shieldと同じようにThe Guildが提供するgraphql-toolsの持つmock機能で解決できるなと分かりました。

テストはjestで書いており、
下記が一部抜粋ですが、テストケースになります。

テスト例(一部抜粋)
import { ApolloServer } from 'apollo-server-micro'
import { addMocksToSchema } from '@graphql-tools/mock'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs } from '@/graphql'
import { UserDocument } from '@/graphql/graphql'
import { applyMiddleware } from 'graphql-middleware'


describe('Example', () => {
  // graphqlのresolverにどういった値を返してほしいか定義
  const mocks = {
    ID: () => { return randomUUID() },
  }

 // 実装と同じようにschemaを作成
  const schema = makeExecutableSchema({ typeDefs, resolvers })
  // graphql-toolsのmockを追加
  const mockSchema = addMocksToSchema({
    schema: schema,
    mocks,
  })
  // mock化されたschemaに対してgraphql-shieldを追加
  const schemaWithMiddleware = applyMiddleware(mockSchema, permissions)

  describe('', () => {
    it('', async () => {
      const testServer = new ApolloServer({
        schema: schemaWithMiddleware,
        context: {
	  // permissionsで検証したい値を渡す
        }
      })

      const res = await testServer.executeOperation({
        // graphqlを渡す必要があるので、graphql-codegenのDocumentを渡してます
        query: UserDocument,
        variables: {
          id
        }
      })
      expect(res.errors).toBeUndefined()
    })
  })
})

概ね、入れているコメントの通りですが、以下のようなことを行ってます。

  1. mockが返す値を定義する
  2. resolverとtypeDefsからschemaを生成する
  3. schemaに対してgraphql-toolsのmockをaddMocksToSchemaで追加する
  4. mockを追加したschemaに対して、今度はgraphql-shieldをmiddlewareとして追加する
  5. ApolloServerにはcontextを渡すことができるので、実際に検証するところでpermissionsで検証したいcontextを引数に持たせてnew ApolloServerでインスタンスを生成する。
  6. ApolloServerにはexecuteOperationメソッドというものがあり、これによりHTTPリクエストを介さずQueryやMutationを実行できるので、実行したいGraphQLをgraphql-codegenのDocumentから渡してmockが生成した実行結果を受け取るようにしています。

認可制御のテストであれば、Query、Mutationが実行できるかどうかを確認できれば良いと思うので、実行結果がmockであろうと問題ないと思っています。

最後に

今回、色々調べながら実装したのでApolloServerに上記のようなメソッドなどが提供されていることや周辺ツールに様々なものがあることを初めて知りました。
BEがTypescriptというのがあまり見ないと思いますが、この実装が誰かの参考になれば幸いです。
また、個人的にはベストな実装が出来たと思っていますが、「他にもこんな方法があるよ」というものがあればコメントいただければと思います。

Discussion