graphql serverの認可制御とテストが個人的にキレイにできた話
まえがき
最近、仕事ではFEもBEもTypescriptで書かれているシステムの改修を主に行っています。
BEはGraphQLなのですがHasuraとかを使っていないピュアなapollo-server
なので、認可制御どうしようかとなっていましたが、納得いく形に収まったのでメンバーへの共有を兼ねてまとめることにしました。
色々探して見つけたgraphql-shield
初めの方は、apolloのドキュメントに書かれているようにResolverの各Query、Mutationの中でContextを検証することを検討しました。
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;
};
...
};
これぐらいの数の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)
}
「他に方法無いか」と調べて見つけたのが、次のスクラップでした。
このスクラップで初めてgraphql-shieldというものの存在を知りました。The Guildと呼ばれるオープンソース開発グループによって提供されており、
このgraphql-shieldはapollo-serverに対してMiddlewareの形で認可レイヤーを提供することができます。
ここには例しか載せないですが、graphql-shieldによって実際かなりシンプルに認可制御を書くことができました。
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,
})
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()
})
})
})
概ね、入れているコメントの通りですが、以下のようなことを行ってます。
- mockが返す値を定義する
- resolverとtypeDefsからschemaを生成する
- schemaに対してgraphql-toolsのmockをaddMocksToSchemaで追加する
- mockを追加したschemaに対して、今度はgraphql-shieldをmiddlewareとして追加する
- ApolloServerにはcontextを渡すことができるので、実際に検証するところでpermissionsで検証したいcontextを引数に持たせてnew ApolloServerでインスタンスを生成する。
- ApolloServerにはexecuteOperationメソッドというものがあり、これによりHTTPリクエストを介さずQueryやMutationを実行できるので、実行したいGraphQLをgraphql-codegenのDocumentから渡してmockが生成した実行結果を受け取るようにしています。
認可制御のテストであれば、Query、Mutationが実行できるかどうかを確認できれば良いと思うので、実行結果がmockであろうと問題ないと思っています。
最後に
今回、色々調べながら実装したのでApolloServerに上記のようなメソッドなどが提供されていることや周辺ツールに様々なものがあることを初めて知りました。
BEがTypescriptというのがあまり見ないと思いますが、この実装が誰かの参考になれば幸いです。
また、個人的にはベストな実装が出来たと思っていますが、「他にもこんな方法があるよ」というものがあればコメントいただければと思います。
Discussion