권한 계층이 필요한 이유
쉽게 생각해 볼 수 있는 권한 체계는 계층이 있는 권한이다.
간단하게 게시판 서비스에 권한이 3개 존재한다고 가정해 보자.
- 일반 사용자: 게시글 조회만 가능
- VIP: 글 작성 가능
- 관리자: 삭제/수정 관리 가능
이때 VIP 권한에 일반 사용자 권한이 가지고 있는 게시글 조회 권한이 없다는 것은 상식상 이해할 수 없다.
마찬가지 관리자가 게시글 조회 및 작성 권한을 가지는 것이 당연해 보인다.
스프링 시큐리티에서는 기본적으로 권한마다 허용 가능한 API를 별도로 설정해야 하는데,
이러면 공통 허용 API가 추가될 때 마다 공통 권한을 만들고 공통 권한을 부여해야한다.
이런 문제를 해결하고자 스프링 시큐리티에서는 RoleHierarchy를 제공한다.
스프링부트 3.2.3 기준으로 작성되어 있습니다.
@Bean
fun roleHierarchy(): RoleHierarchy {
val roleHierarchy = RoleHierarchyImpl()
roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_BASIC > ROLE_VIEWER > ROLE_GUEST")
return roleHierarchy
}
'>' 구분자로 권한 체계를 지정할 수 있다. 정말 쉽다.
스프링부트 3.3 부터 위 방식은 Deprecated 되었고 권한 계층 정의 방식이 쉽게 변경되었다.
Authorization Architecture :: Spring Security
It is a common requirement that a particular role in an application should automatically "include" other roles. For example, in an application which has the concept of an "admin" and a "user" role, you may want an admin to be able to do everything a normal
docs.spring.io
// 변경된 코드
@Bean
fun roleHierarchy(): RoleHierarchy {
val roleHierarchy = RoleHierarchyImpl.withDefaultRolePrefix()
.role(ADMIN).implies(BASIC, SYSTEM)
.role(BASIC).implies(VIEWR)
.role(VIEWR).implies(GUEST)
.build()
return roleHierarchy
}
설정
권한 API 매핑 Enum 코드
enum class Role2ApiMapping(
val requests: RequestMatcher,
) {
EVERYONE(
anyOf(
antMatcher(GET, "/error"),
antMatcher(GET, "/actuator/health/**"),
antMatcher(POST, "/api/token"),
antMatcher(GET, "/docs/**"),
)
),
GUEST(
anyOf(
antMatcher(GET, "/api/user/members/me"),
...
)
),
BASIC(
...
)
...
권한별로 접근 가능한 API를 간단하게 enum에 정의했다.
내가 기대하는 것은 계층 정의 후 BASIC 권한만을 가진 사용자가 GUEST 권한이 접근할 수 있는 API에 접근하는 것이다.
필터 체인 코드
만약 권한이 동적으로 생성되고 수정되는 구조라면
AuthorizationManager 인터페이스를 직접 구현해서 사용하는 것을 권장합니다.
@Bean
fun filterChain(
http: HttpSecurity,
corsConfigurationSource: UrlBasedCorsConfigurationSource,
jwtProvider: JwtProvider,
): SecurityFilterChain {
http {
...
authorizeHttpRequests {
authorize(EVERYONE.requests, permitAll)
authorize(GUEST.requests, hasRole("GUEST"))
authorize(VIEWER.requests, hasRole("VIEWER"))
authorize(BASIC.requests, hasRole("BASIC"))
authorize(anyRequest, hasRole("ADMIN"))
}
...
return http.build();
}
위와 같이 설정을 하고 BASIC 권한에서 하위 권한이 가진 API를 호출했는데..
403 응답이 발생한다. 즉, 권한 계층이 제대로 정의되지 않았다는 것을 의미한다.
원인
원인 분석을 위해 코드를 찾아봤다.

hasRole 함수를 호출하면 AuthorizationManager 인스턴스가 생성된다.
더 따라가보자

인스턴스 생성과 동시에 roleHierarchy 필드에는 아무런 동작을 하지않는 NullRoleHierarchy 구현체가 할당된다.
그 아래 java doc을 읽어보면 권한 계층을 적용하고 싶을 때 setRoleHierarchy 메소드를 사용하라고 안내되어있다.

hasRole 함수를 사용하여 AuthorizationManager를 할당했을 때
앞서 정의한 RoleHierarchy Bean이 할당되지 않고 기본 NullRoleHierarchy 가 동작하기 때문에
계층이 적용이 되지 않는 것이다.
방법을 찾았으니 setRoleHierarchy 만 호출하면 될텐데 DSL 코드를 찾아봐도 제공되는 함수는 찾아보지 못했다.
해결법
1. hasRole 함수로 생성된 AuthorizationManager에 직접 세터 메소드를 호출해서 적용
2. RoleHierarchy가 적용된 AuthorizationManager를 만들어주는 private 함수를 만들기
3. 코틀린의 확장함수를 사용
위 방법도 모두 가능하지만
나는 DSL 표현을 유지하고 싶어 중위 함수를 사용했다.
private infix fun <T> AuthorizationManager<T>.with(roleHierarchy: RoleHierarchy): AuthorizationManager<T> {
check(this is AuthorityAuthorizationManager) { "권한 계층을 적용할 수 없습니다." }
this.setRoleHierarchy(roleHierarchy)
return this
}
hasRole이 반환하는 Manager에 RoleHierarchy 를 넘겨주는 간단한 함수이다.
이로서 다음과 같이 DSL 표현을 해치지 않고 권한 계층을 적용할 수 있다.

Role 이름 까지 enum에 정의하면 더 좋다.
아쉬운 점
충분히 시큐리티에서 제공할 법한데 그렇지 않은 게 조금 아쉽다.
아마 자유로운 문법으로 사용자가 마음 껏 정의하라는 취지에서 열어놓지 않았을까 싶다.
찾아보니 비슷한 이슈가 올라왔었다.
https://github.com/spring-projects/spring-security/issues/13911
읽어보면 메소드 만들어서 사용하라고 안내해 준다..
좋은 방법이 있다면 댓글로 공유 부탁드립니다.