はじめに
みなさん、こんにちは。空中清高です。
この記事は、株式会社エス・エム・エス Advent Calendar 2023の4日目の投稿です。
MetaAnnotation
SpringではMetaAnnotationという機能がサポートされています。
MetaAnnotationはAnnotationにAnnotationをつける機能です。
使用例として認可処理を実装するAnnotationの@PreAuthorizeを使って説明したいと思います。
@PreAuthorize
@PreAuthorizeはメソッドにつけることで認可処理が実装できるAnnotationです。
例えばSpring for GraphQLでGraphQL APIを開発している場合、HTTPエンドポイントは一つになります。
そのためURLエンドポイント毎に認可処理を実装するRequest levelの認可処理では細かな認可処理は実装できません。
またSpring for GraphQLではqueryやmutationはControllerコンポーネントのメソッドにマッピングして実装します。
上記の事情によりSpring for GraphQLでの認可処理は@PreAuthorizeなどを使ったメソッド毎に認可処理を実装するMethod Securityの方法で実装していくことになります。
例としてOfficeに対して読み取り権限を持っているかどうかを確認するため、次のような実装をしたとします。
type Query {
office: Office
}
type Office {
id: ID!
name: String!
}
@Component
class OfficeAuthorizer {
fun canRead(principal: Principal): Boolean {
return // principal からOfficeへの読み取り権限があることを確認してBooleanを返す
}
}
@Controller
class OfficeController {
@PreAuthorize("@officeAuthorizer.canRead(principal)")
@QueryMapping
fun office(): Office
}
GraphQLのofficequeryを実装するためOfficeController#officeに@QueryMappingをつけています。
また認可処理を実装するためOfficeController#officeに@PreAuthorizeをつけています。
@PreAuthorizeではSpEL式の記述ができるので、Spring ComponentのOfficeAuthorizerを使って認可処理を実装しています。
MetaAnnotationを使う
@PreAuthorizeはSpEL式が渡せるため表現力は高いのですが書き間違いを防ぐために特定のSpEL式を渡しているものが欲しくなります。
そこで@PreAuthorizeCanReadOfficeというMetaAnnotationを定義して書き換えてみます。
@Component
class OfficeAuthorizer {
fun canRead(principal: Principal): Boolean {
return // principal からOfficeへの読み取り権限があることを確認してBooleanを返す
}
}
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@PreAuthorize("@officeAuthorizer.canRead(principal)")
annotation class PreAuthorizeCanReadOffice
@Controller
class OfficeController() {
@PreAuthorizeCanReadOffice
@QueryMapping
fun office(): Office
}
このように実装すると他のメソッドに同じ認可処理を実装したい時も書き間違いをしなくなりますし、認可処理のAnnotationを書くときにコード補完も効いて良いです。
ユニットテストで@PreAuthorizeを検出する
さて、上記のようにMethod Securityを使って認可処理を実装するときは@PreAuthorizeを付け忘れてしまうと素通りになってしまうため、ユニットテストなどでちゃんと付けられていることを確認したくなります。
そこでReflectionを使ったコード解析で確認しようとすると、こんな感じになります。
class ControllerTest {
@Test
fun `publicメソッドにPreAuthorizeアノテーションが付けられている`() {
findControllers().forEach { controller ->
// publicでstaticでないメソッドに対して@PreAuthorizeが付けられているかチェックする
controller.declaredMethods
.filter { Modifier.isPublic(it.modifiers) && !Modifier.isStatic(it.modifiers) }
.forEach { method ->
val methodName = "${controller.canonicalName}#${method.name}"
Assert.assertTrue(
method.isAnnotationPresent(PreAuthorize::class.java)
) { "$methodName must be annotated with PreAuthorize" }
}
}
}
private fun findControllers() =
ClassPathScanningCandidateComponentProvider(false)
.apply { addIncludeFilter(AnnotationTypeFilter(Controller::class.java)) }
.findCandidateComponents("com.example.presentation")
.map { it.beanClassName }
.map { Class.forName(it) }
}
このテストではcom.example.presentationパッケージ配下にある@ControllerAnnotationが付けられているクラスを探してきて、そのクラスのpublicでstaticでないメソッドに@PreAuthorizeが付けられていることを確認するようになっています。
これで@PreAuthorizeをつけ忘れてしまって素通りになっていないかどうかを確認できていそうです。
しかし、MetaAnnotationが使われている場合はこれではチェックできません。
MetaAnnotationを使って認可処理を実装している場合、method.isAnnotationPresent(PreAuthorize::class.java)はfalseになってしまいます。
なぜかというと単純にReflectionで取得したmethodには@PreAuthorizeCanReadOfficeと @QueryMappingしか付いていないからです。
そのため上に書いたユニットテストはMetaAnnotationが使われているとコケるようになってしまいます。
MetaAnnotationを検出する
ようやく記事の題名まで辿り着きました。
このようなMetaAnnotationを検出するためにMergedAnnotationsというクラスがあります。
MergedAnnotationsは合成したAnnotation情報を取り扱うためのクラスでMergedAnnotations#isPresentを使うとMetaAnnotationも含めてチェックできるようになります。
class ControllerTest {
@Test
fun `publicメソッドにPreAuthorizeアノテーションが付けられている`() {
findControllers().forEach { controller ->
// publicでstaticでないメソッドに対して@PreAuthorizeが付けられているかチェックする
controller.declaredMethods
.filter { Modifier.isPublic(it.modifiers) && !Modifier.isStatic(it.modifiers) }
.forEach { method ->
val methodName = "${controller.canonicalName}#${method.name}"
Assert.assertTrue(
// MetaAnnotation に対応するため MergedAnnotations を使う
MergedAnnotations.from(method, SearchStrategy.TYPE_HIERARCHY).isPresent(PreAuthorize::class.java)
) { "$methodName must be annotated with PreAuthorize" }
}
}
}
private fun findControllers() =
ClassPathScanningCandidateComponentProvider(false)
.apply { addIncludeFilter(AnnotationTypeFilter(Controller::class.java)) }
.findCandidateComponents("com.example.presentation")
.map { it.beanClassName }
.map { Class.forName(it) }
}
これでMetaAnnotationが使われていても@PreAuthorizeをつけ忘れてしまって素通りになっていないかどうかを確認できるようになりました。
ただし、このテストではあくまで「@PreAuthorizeが付いていて、なんらかの認可処理を実装している」ということしか確認できないため、正しい認可処理が実装されているかどうかは個別のケース毎にちゃんとテストする必要がありますので補助的に使うことを目的にしてください。
補足1
MergedAnnotationsを直接使う他にAnnotationUtilsを使ってAnnotationUtils.findAnnotation(method, PreAuthorize::class.java)って探しても良いです。
補足2
com.example.presentationパッケージ配下にある@ControllerAnnotationが付けられているクラスを探してくる時もMetaAnnotationを考慮することができます。
それは単にAnnotationTypeFilterのコンストラクタに第二引数を与えてAnnotationTypeFilter(Controller::class.java, true))とすれば良いです。
ただ、@ControllerでMetaAnnotationを使う具体的な場面は思いつきませんでした。