Hybrid RBAC (Role + Permission)
Hybrid RBAC (Role + Permission) 접근 제어
https://github.com/implement-study-25/ticket-reserve-service/pull/14
티켓 예매 서비스에서의 Role & Privilege 기반 접근 제어(R&PBAC) 설계와 구현
요약
핵심: JWT로 사용자 식별 → 별도 필터가 DB에서
roles + privileges
로딩 →@PreAuthorize("hasAuthority(..)")
로 인가.장점: 역할 폭발 방지, 무배포 정책 변경, 셀프서비스 운영, 감사 용이, 기간제 권한, 캐시 확장.
구현:
SecurityConfig
에@EnableMethodSecurity
,GrantedAuthorityDefaults("")
, 필터 체인.JwtAuthenticationFilter
는 식별만,RbacAuthoritiesFilter
는 권한 주입.
왜 R&PBAC인가? (RBAC과의 차이 및 장점)
RBAC(Role-Based Access Control): 사용자는 하나 이상의 역할(Role)을 갖고, 리소스 접근은 역할 단위로 제어한다. 단순하고 관리가 쉽지만, 역할이 세분화될수록 역할 폭발(Role Explosion) 문제가 발생한다.
R&PBAC(Role & Privilege-Based Access Control): 역할과 함께 보다 세밀한 권한(Privilege) 을 별도로 정의하고, 역할과 권한을 매핑한다. 결과적으로 사용자의 권한은
역할 + 권한
의 합으로 구성된다.장점
세분화된 권한 설계:
ADMIN
,USER
같은 거친 역할과EVENT_CREATE
,EVENT_UPDATE
같은 미세 권한을 분리해 관리.역할 폭발 방지: 역할 수는 적정 수준으로 유지하고, 신규 기능은 권한만 추가해 역할-권한 매핑으로 유연하게 확장.
정책 변경 용이성: 비즈니스 변화(새 기능, 한시적 오퍼레이션)에 맞춰 권한만 수정/추가 가능.
RBAC 대비 차이점
RBAC은 역할만으로 접근 제어. R&PBAC은 역할과 권한을 동시에 사용하며, 최종 인가 판단 시 두 집합을 모두
GrantedAuthority
로 주입해hasAuthority
로 일관되게 검증.
RBAC을 도입하지 않았을 때의 문제와, R&PBAC 도입 후 해결
미도입 시 발생 문제
코드 곳곳에서 임시 플래그, 하드코딩된 이메일/사용자 체크 등으로 접근 제어 로직이 분산·중복.
권한 변경 요구 시 코드 수정 범위가 광범위하고 테스트 비용 증가.
기능 단위 권한 위임(예: 관리자 중 특정 작업만 가능한 운영자) 구현 난이도 급상승.
R&PBAC 도입 후 개선
인증과 인가를 분리: JWT 기반 인증 필터는 사용자 식별까지만, 이후 R&PBAC 필터가 역할·권한을 로딩해
SecurityContext
에 주입.컨트롤러/서비스 단에는
@PreAuthorize("hasAuthority('...')")
만 배치 → 단일 진입점으로 가독성과 변경 용이성 향상.역할·권한 모델이 DB로 관리되어 운영 환경에서 정책 변경이 코드 배포 없이 가능(매핑 변경만으로 반영).
구현 구조 개요
위치:
com.study.ticketservice.common.security
,com.study.ticketservice.domain.auth
,com.study.ticketservice.utils
핵심 흐름
JwtAuthenticationFilter
가 쿠키의 Access Token을 검증하고principal
에userId
만 설정RbacAuthoritiesFilter
가 DB에서roles + privileges
를 로딩해GrantedAuthority
로 주입@PreAuthorize("hasAuthority('...')")
로 엔드포인트/메서드 보호GrantedAuthorityDefaults("")
로 ROLE_ 프리픽스 제거 →ADMIN
,USER
,EVENT_*
그대로 사용
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public GrantedAuthorityDefaults grantedAuthorityDefaults() {
return new GrantedAuthorityDefaults("");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/actuator/**",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rbacAuthoritiesFilter, JwtAuthenticationFilter.class);
return http.build();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.equals("/api/v1/auth/login") || uri.equals("/api/v1/auth/logout");
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return uri.equals("/api/v1/auth/login")
|| uri.equals("/api/v1/auth/logout")
|| uri.equals("/api/v1/auth/refresh");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals("/api/v1/auth/refresh")) {
filterChain.doFilter(request, response);
return;
}
String token = jwtUtil.extractAccessTokenFromCookie(request);
if (!StringUtils.hasText(token)) {
filterChain.doFilter(request, response);
return;
}
try {
Claims claims = jwtUtil.extractAccessClaims(token);
String type = claims.get("type", String.class);
if (!"access".equals(type)) throw new BadCredentialsException("INVALID_TOKEN");
Number userIdNum = claims.get("userId", Number.class);
if (userIdNum == null) throw new BadCredentialsException("MISSING_USER_ID");
Long userId = userIdNum.longValue();
// 사용자 식별만 설정, 권한은 R&PBAC 필터에서 주입
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired");
return;
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
filterChain.doFilter(request, response);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
filterChain.doFilter(request, response);
return;
}
Object principal = authentication.getPrincipal();
if (!(principal instanceof Long userId)) {
filterChain.doFilter(request, response);
return;
}
Set<String> authorities = getAuthorities(userId); // Role & Privilege 모두 추가
List<SimpleGrantedAuthority> granted = authorities.stream().map(SimpleGrantedAuthority::new).toList();
UsernamePasswordAuthenticationToken newAuth = new UsernamePasswordAuthenticationToken(
userId, authentication.getCredentials(), granted);
SecurityContextHolder.getContext().setAuthentication(newAuth);
filterChain.doFilter(request, response);
}
private Set<String> getAuthorities(Long userId) {
List<String> roleNames = userRoleMapRepository.findRoleNamesByUserId(userId);
Set<String> authorities = new HashSet<>(roleNames);
if (!roleNames.isEmpty()) {
List<UserRoleMap> userRoles = userRoleMapRepository.findAllByUserUserId(userId);
List<Long> roleIds = userRoles.stream().map(urm -> urm.getRole().getRoleId()).toList();
if (!roleIds.isEmpty()) {
List<String> privilegeNames = rolePrivilegeMapRepository.findPrivilegeNamesByRoleIds(roleIds);
authorities.addAll(privilegeNames);
}
}
return authorities;
}
도메인 모델링 (엔티티 & 매핑)
사용자-역할 다대다:
users
↔roles
를user_roles
로 매핑역할-권한 다대다:
roles
↔privileges
를role_privileges
로 매핑엔티티
User
,Role
,Privilege
,UserRoleMap
,RolePrivilegeMap
@Entity
@Table(name = "user_roles")
public class UserRoleMap {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_role_id")
private Long userRoleId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
}
@Entity
@Table(name = "role_privileges")
public class RolePrivilegeMap {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "role_privilege_id")
private Long rolePrivilegeId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false)
private Role role;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "privilege_id", nullable = false)
private Privilege privilege;
}
조회 쿼리
@Query("select r.name from UserRoleMap urm join urm.role r where urm.user.userId = :userId")
List<String> findRoleNamesByUserId(@Param("userId") Long userId);
@Query("select distinct p.name from RolePrivilegeMap map join map.privilege p where map.role.roleId in :roleIds")
List<String> findPrivilegeNamesByRoleIds(@Param("roleIds") Collection<Long> roleIds);
컨트롤러 인가 적용 예시
관리자 전용 이벤트 관리
@PostMapping("/v1/admin/events")
@PreAuthorize("hasAuthority('EVENT_CREATE')")
public ResponseEntity<ApiResponse<EventDetailResponse>> createEvent(@Valid @RequestBody EventCreateRequest request) {
EventDetailResponse response = eventService.createEvent(request);
return ApiResponse.success(201, response);
}
사용자 좌석 예약
@PostMapping("/v1/reservations")
@PreAuthorize("hasAuthority('EVENT_SEAT_RESERVE')")
public ResponseEntity<ApiResponse<ReservationCreateResponse>> reserveEventSeats(
@RequestBody ReservationCreateRequest request,
@AuthenticationPrincipal Long userId,
@RequestHeader(value = "X-Idempotency-Key") String idempotencyKey) {
ReservationCreateResponse result = reservationService.reservation(request, userId, idempotencyKey);
return ApiResponse.success(result);
}
통합 데모(RBAC vs Privilege 비교)
@GetMapping("/admin/event/create")
@PreAuthorize("hasAuthority('ADMIN')")
public ResponseEntity<String> adminCreateByRole() { return ResponseEntity.ok("EVENT_CREATE_OK"); }
@GetMapping("/admin/event/create/privilege")
@PreAuthorize("hasAuthority('EVENT_CREATE')")
public ResponseEntity<String> adminCreateByPrivilege() { return ResponseEntity.ok("EVENT_CREATE_OK"); }
인증(JWT)과 쿠키 전략
JwtUtil
Access/Refresh 분리, 각기 다른 시크릿/만료
accessToken
,refreshToken
를HttpOnly
,Secure
쿠키로 설정 (프론트는credentials: 'include'
필요)토큰 페이로드:
userId
,roles
,type
public String createAccessToken(Long userId, List<String> roles) {
return Jwts.builder()
.setSubject("AccessToken")
.claim("userId", userId)
.claim("roles", roles)
.claim("type", "access")
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSecretKey(ACCESS_TOKEN_SECRET))
.compact();
}
리프레시 플로우
public String refresh(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = jwtUtil.extractRefreshTokenFromCookie(request);
if (!StringUtils.hasText(refreshToken) || !jwtUtil.validateRefreshToken(refreshToken)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return null;
}
var claims = jwtUtil.extractRefreshClaims(refreshToken);
Long userId = ((Number) claims.get("userId")).longValue();
List<String> roles = ((List<?>) claims.get("roles")).stream().map(String::valueOf).toList();
String newAccess = jwtUtil.createAccessToken(userId, roles);
String newRefresh = jwtUtil.createRefreshToken(userId, roles);
jwtUtil.setTokenCookies(response, newAccess, newRefresh);
return "ACCESS_TOKEN_REFRESHED";
}
예외 처리(인가/인증/공통)
인가 실패: 403
Access Denied
통일인증 실패: 401
Unauthorized
통일도메인 예외:
ApiException
→ 일관 응답 포맷
@ExceptionHandler({AccessDeniedException.class, AuthorizationDeniedException.class})
public ResponseEntity<ErrorResponse> handleAccessDenied(Exception e) {
return ErrorResponse.error(403, "Access Denied");
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
return ErrorResponse.error(401, "Unauthorized");
}
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException e) {
return ErrorResponse.error(e);
}
주요 포인트:
권한 주입 이원화: 인증 필터에서 사용자 식별만, 별도 필터에서
roles + privileges
를 로딩 후 주입 → 성능/책임 분리 명확.ROLE_ 프리픽스 제거:
GrantedAuthorityDefaults("")
로ADMIN
,USER
,EVENT_*
그대로 사용 → 팀/프론트와의 커뮤니케이션 비용 절감.DB 기반 동적 인가:
UserRoleMapRepository
,RolePrivilegeMapRepository
쿼리로 인가 정책을 DB만 바꿔도 즉시 반영 가능.쿠키 기반 JWT:
HttpOnly + Secure
쿠키로 XSS 취약점 완화,SameSite=None
(운영 환경) 적용 전제.테스트 픽스처 제공:
rbac-test-data.sql
로 역할/권한/매핑을 한눈에 확인 가능 → 데모/테스트 재현 용이.
-- roles
INSERT INTO roles (role_id, name) VALUES (1, 'ADMIN'), (2, 'USER');
-- privileges
INSERT INTO privileges (privilege_id, name) VALUES
(1, 'EVENT_CREATE'),
(2, 'EVENT_UPDATE'),
(3, 'EVENT_CHANGE_STATUS'),
(4, 'EVENT_SEAT_RESERVE'),
(5, 'EVENT_SEAT_CANCEL');
-- mappings
INSERT INTO role_privileges (role_privilege_id, role_id, privilege_id) VALUES
(1, 1, 1), (2, 1, 2), (3, 1, 3), (4, 2, 4), (5, 2, 5);
운영 효과: 개발자 개입 최소화(셀프서비스 권한 관리)
무배포 정책 변경: 인가 정책을 코드가 아닌 데이터(
user_roles
,role_privileges
)로 관리하므로, 운영/보안 담당자가 UI나 승인된 배치로 매핑만 변경하면 즉시 반영된다. 개발자가 데이터를 직접 조작하거나 코드를 고쳐 배포할 필요가 없다.셀프서비스 권한 관리: 어드민 콘솔에서 사용자-역할, 역할-권한 매핑을 관리하도록 만들기 쉽다. 온보딩/조직 변경 때 운영자가 스스로 권한을 부여/회수 가능.
정책 거버넌스와 감사(Audit): 매핑 변경 이력을 별도 테이블로 남기고(예:
auth_policy_change_log
), 승인 워크플로를 붙이면 누가 언제 어떤 권한을 변경했는지 추적 가능.임시/시간제한 권한 부여: 매핑 테이블에
granted_until
같은 유효기간 컬럼을 확장하고,RbacAuthoritiesFilter
조회 시 현재 시각 기준으로 필터링하면 기간 한정 권한을 안전하게 운용 가능.롤백과 실험(A/B): 새로운 기능 권한을 역할에 점진적으로 붙였다 떼는 방식으로 실험과 롤백이 쉽다. 실패 시 매핑만 되돌리면 된다.
멀티테넌시/팀별 정책 응용: 권한 네임스페이스(예:
ORG_A:EVENT_CREATE
)를 도입하면 테넌트/조직별로 다른 정책을 같은 코드로 운용 가능.
한 줄 요약: R&PBAC은 접근 정책을 “코드”가 아니라 “데이터”로 다루게 하므로, 운영이 개발자에게 의존하지 않고 자율·민첩하게 권한을 관리할 수 있게 만든다.
마무리
본 프로젝트는 역할과 권한을 분리해 유연성과 유지보수성을 확보했고, 스프링 시큐리티 필터 체인을 활용해 인증과 인가의 책임을 명확히 분리했습니다.
컨트롤러 단의 인가는
@PreAuthorize("hasAuthority('...')")
로 일관되게 표현되어 가독성이 높고, 운영 중 정책 변경에도 신속히 대응 가능합니다.
Last updated