はじめに
みなさん、こんにちは。空中清高です。
この記事は、株式会社エス・エム・エス 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のoffice
queryを実装するため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
パッケージ配下にある@Controller
Annotationが付けられているクラスを探してきて、そのクラスの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
パッケージ配下にある@Controller
Annotationが付けられているクラスを探してくる時もMetaAnnotationを考慮することができます。
それは単にAnnotationTypeFilter
のコンストラクタに第二引数を与えてAnnotationTypeFilter(Controller::class.java, true))
とすれば良いです。
ただ、@Controller
でMetaAnnotationを使う具体的な場面は思いつきませんでした。