diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java index 3b29d2ce..cbe14f94 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilter.java @@ -23,9 +23,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; private final TokenBlacklistService tokenBlacklistService; + private final RoleAuthorityService roleAuthorityService; @Override - @SuppressWarnings("unchecked") protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -36,7 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, if (claims != null && !isBlacklisted(claims.get("jti", String.class))) { Long userId = claims.get("userId", Long.class); String email = claims.getSubject(); - List roles = (List) claims.get("roles"); + List roles = roleAuthorityService.getRoles(userId); List authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java index 23608bf9..860d935b 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/JwtProvider.java @@ -2,7 +2,6 @@ import java.nio.charset.StandardCharsets; import java.util.Date; -import java.util.List; import java.util.UUID; import javax.crypto.SecretKey; @@ -32,14 +31,14 @@ public JwtProvider( this.expirationSeconds = expirationSeconds; } - public String generateToken(Long userId, String email, List roles) { + public String generateToken(Long userId, String email) { Date now = new Date(); Date expiry = new Date(now.getTime() + expirationSeconds * 1000); + // role은 RoleAuthorityService가 매 요청 DB(+Redis 캐시)에서 조회하므로 토큰에 담지 않는다. return Jwts.builder() .subject(email) .claim("userId", userId) - .claim("roles", roles) .claim("jti", UUID.randomUUID().toString()) .issuedAt(now) .expiration(expiry) diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityService.java new file mode 100644 index 00000000..758c0453 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityService.java @@ -0,0 +1,75 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 인가(hasRole) 판단에 쓰는 사용자 role을 JWT가 아니라 DB에서 매 요청 조회한다. + * + *

JWT에 role을 박제하면 관리자가 role을 부여/회수해도 재로그인 전까지 반영되지 않는다. + * DB 조회 부하를 줄이기 위해 Redis에 짧은 TTL로 캐싱하고, role 변경 시 즉시 무효화한다. + * + *

이 서비스는 인증 필터(모든 요청)의 critical path에 있으므로, {@code TokenBlacklistService}와 + * 동일하게 Redis 장애 시 예외를 전파하지 않고 DB 조회로 폴백한다 — Redis가 죽었다고 전체 API가 + * 막히면 안 된다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RoleAuthorityService { + + private static final String KEY_PREFIX = "auth:roles:"; + private static final Duration TTL = Duration.ofSeconds(30); + + private final StringRedisTemplate redisTemplate; + private final UserRoleRepository userRoleRepository; + + public List getRoles(Long userId) { + // 1. 먼저 Redis 캐시를 확인한다 — 대부분의 요청은 여기서 끝나 DB 부하를 줄인다. + String cached = readCache(userId); + if (cached != null) { + return cached.isBlank() ? List.of() : Arrays.asList(cached.split(",")); + } + + // 2. 캐시 미스면 DB에서 최신 role을 조회한다(source of truth). + List roles = userRoleRepository.findRoleCodesByUserId(userId); + + // 3. 다음 요청부터는 캐시로 처리되도록 짧은 TTL로 저장해둔다. + writeCache(userId, roles); + return roles; + } + + public void invalidate(Long userId) { + try { + redisTemplate.delete(KEY_PREFIX + userId); + } catch (Exception e) { + log.error("Redis role 캐시 무효화 실패, userId={}: {}", userId, e.getMessage()); + } + } + + private String readCache(Long userId) { + try { + return redisTemplate.opsForValue().get(KEY_PREFIX + userId); + } catch (Exception e) { + log.error("Redis role 캐시 조회 실패, DB로 폴백합니다. userId={}: {}", userId, e.getMessage()); + return null; + } + } + + private void writeCache(Long userId, List roles) { + try { + redisTemplate.opsForValue().set(KEY_PREFIX + userId, String.join(",", roles), TTL); + } catch (Exception e) { + log.error("Redis role 캐시 저장 실패, userId={}: {}", userId, e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java index e373b4ac..bedcdbab 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptor.java @@ -51,9 +51,9 @@ public class StompAuthChannelInterceptor implements ChannelInterceptor { private static final String BEARER_PREFIX = "Bearer "; private final JwtProvider jwtProvider; + private final RoleAuthorityService roleAuthorityService; @Override - @SuppressWarnings("unchecked") public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); @@ -68,7 +68,7 @@ public Message preSend(Message message, MessageChannel channel) { String email = claims.getSubject(); Long userId = claims.get("userId", Long.class); - List roles = (List) claims.get("roles"); + List roles = roleAuthorityService.getRoles(userId); List authorities = roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .toList(); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java index 79024719..cd703bf4 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandService.java @@ -98,7 +98,7 @@ public LoginResponse login(LoginRequest request) { List roles = userRoleRepository.findRoleCodesByUserId(user.getId()); - String token = jwtProvider.generateToken(user.getId(), user.getEmail(), roles); + String token = jwtProvider.generateToken(user.getId(), user.getEmail()); return LoginResponse.of(token, jwtProvider.getExpirationSeconds(), user.getId(), user.getEmail(), roles); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java index ea0d6282..28debd89 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/controller/AdminUserController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -71,6 +72,18 @@ public ResponseEntity> assignRole( return ResponseUtils.ok(userRoleCommandService.assignRole(userId, adminUserId, request)); } + @Operation( + summary = "역할 회수", + description = "특정 사용자에게 부여된 역할을 회수합니다. ADMIN 권한이 필요합니다. " + + "부여되지 않은 역할이면 404를 반환합니다. 재로그인 없이 다음 요청부터 즉시 반영됩니다." + ) + @DeleteMapping("/{userId}/roles/{roleCode}") + public ResponseEntity> revokeRole( + @PathVariable Long userId, + @PathVariable String roleCode) { + return ResponseUtils.ok(userRoleCommandService.revokeRole(userId, roleCode)); + } + @Operation( summary = "사용자 부서 변경", description = "특정 사용자의 소속 부서를 변경합니다. ADMIN 권한이 필요합니다. " diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java index de9a1f15..ce131452 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -1,6 +1,7 @@ package com.opensource.docgrid.domain.user.repository; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -30,4 +31,6 @@ public interface UserRoleRepository extends JpaRepository { List findRoleCodesByUserId(@Param("userId") Long userId); boolean existsByUserIdAndRoleCode(Long userId, String roleCode); + + Optional findByUserIdAndRoleCode(Long userId, String roleCode); } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java b/backend/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java index 6ccbe115..7ec5262f 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandService.java @@ -5,7 +5,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.user.dto.request.AssignRoleRequest; import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; import com.opensource.docgrid.domain.user.entity.Role; @@ -27,6 +30,7 @@ public class UserRoleCommandService { private final UserRepository userRepository; private final RoleRepository roleRepository; private final UserRoleRepository userRoleRepository; + private final RoleAuthorityService roleAuthorityService; // 관리자가 다른 사용자에게 역할을 부여 public UserRoleResponse assignRole(Long targetUserId, Long adminUserId, AssignRoleRequest request) { @@ -50,6 +54,7 @@ public UserRoleResponse assignRole(Long targetUserId, Long adminUserId, AssignRo .assignedAt(LocalDateTime.now()) .build(); userRoleRepository.save(userRole); + invalidateAfterCommit(targetUserId); List roles = userRoleRepository.findAllWithRoleByUserId(targetUserId).stream() .map(ur -> ur.getRole().getCode()) @@ -57,4 +62,38 @@ public UserRoleResponse assignRole(Long targetUserId, Long adminUserId, AssignRo return UserRoleResponse.of(targetUser, roles); } + + // 관리자가 다른 사용자에게 부여된 역할을 회수 + public UserRoleResponse revokeRole(Long targetUserId, String roleCode) { + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new DocGridException(ErrorCode.USER_NOT_FOUND)); + + UserRole userRole = userRoleRepository.findByUserIdAndRoleCode(targetUserId, roleCode) + .orElseThrow(() -> new DocGridException(ErrorCode.ROLE_NOT_ASSIGNED)); + + userRoleRepository.delete(userRole); + invalidateAfterCommit(targetUserId); + + List roles = userRoleRepository.findAllWithRoleByUserId(targetUserId).stream() + .map(ur -> ur.getRole().getCode()) + .toList(); + + return UserRoleResponse.of(targetUser, roles); + } + + // DB 커밋 전에 캐시를 지우면, 커밋 직전 시점에 캐시 미스가 난 다른 요청이 아직 커밋 안 된(옛날) role을 + // 다시 캐시에 채워 넣을 수 있다. 그래서 무효화는 반드시 트랜잭션 커밋 이후로 미룬다. + // 트랜잭션 밖에서 호출되는 경우(예: 단위 테스트)는 즉시 무효화한다. + private void invalidateAfterCommit(Long userId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + roleAuthorityService.invalidate(userId); + } + }); + } else { + roleAuthorityService.invalidate(userId); + } + } } diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index 501cdfc7..dc541525 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -14,6 +14,7 @@ import com.opensource.docgrid.domain.auth.jwt.JwtAuthenticationFilter; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.security.McpApiKeyAuthFilter; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; @@ -28,6 +29,7 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; private final JwtProvider jwtProvider; private final TokenBlacklistService tokenBlacklistService; + private final RoleAuthorityService roleAuthorityService; private final McpAccessTokenCommandService mcpAccessTokenCommandService; @Bean @@ -57,7 +59,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { * - JwtAuthenticationFilter → 웹 로그인(JWT), /mcp/tokens 등 일반 API 담당 * - McpApiKeyAuthFilter → Claude Desktop API 키, /mcp 경로만 담당 */ - .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new McpApiKeyAuthFilter(mcpAccessTokenCommandService), UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 57d3057e..31015146 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -38,6 +38,7 @@ public enum ErrorCode { ROLE_NOT_FOUND(HttpStatus.BAD_REQUEST, "ROLE-001", "존재하지 않는 역할입니다."), PERMISSION_DENIED(HttpStatus.FORBIDDEN, "ROLE-002", "접근 권한이 없습니다."), ROLE_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "ROLE-003", "이미 부여된 역할입니다."), + ROLE_NOT_ASSIGNED(HttpStatus.NOT_FOUND, "ROLE-004", "부여되지 않은 역할입니다."), // COLLECTION COLLECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "COLLECTION-001", "컬렉션을 찾을 수 없습니다."), diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java index aa8a2e16..bf3b8d99 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtAuthenticationFilterTest.java @@ -27,13 +27,16 @@ class JwtAuthenticationFilterTest { @Mock private TokenBlacklistService tokenBlacklistService; + @Mock + private RoleAuthorityService roleAuthorityService; + private JwtProvider jwtProvider; private JwtAuthenticationFilter filter; @BeforeEach void setUp() { jwtProvider = new JwtProvider(TEST_SECRET, 3600L); - filter = new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService); + filter = new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService); SecurityContextHolder.clearContext(); } @@ -45,8 +48,9 @@ void tearDown() { @Test @DisplayName("유효하고 블랙리스트에 없는 토큰이면 인증에 성공한다") void doFilter_authenticates_whenTokenValidAndNotBlacklisted() throws Exception { - String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + String token = jwtProvider.generateToken(1L, "user@test.com"); given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(false); + given(roleAuthorityService.getRoles(1L)).willReturn(List.of("USER")); filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); @@ -56,7 +60,7 @@ void doFilter_authenticates_whenTokenValidAndNotBlacklisted() throws Exception { @Test @DisplayName("블랙리스트에 등록된 토큰이면 인증하지 않는다") void doFilter_doesNotAuthenticate_whenTokenBlacklisted() throws Exception { - String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + String token = jwtProvider.generateToken(1L, "user@test.com"); given(tokenBlacklistService.isBlacklisted(anyString())).willReturn(true); filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); @@ -67,8 +71,9 @@ void doFilter_doesNotAuthenticate_whenTokenBlacklisted() throws Exception { @Test @DisplayName("블랙리스트 조회가 실패해도(Redis 장애) 인증은 계속 진행된다") void doFilter_authenticates_whenBlacklistCheckFails() throws Exception { - String token = jwtProvider.generateToken(1L, "user@test.com", List.of("USER")); + String token = jwtProvider.generateToken(1L, "user@test.com"); given(tokenBlacklistService.isBlacklisted(anyString())).willThrow(new RuntimeException("redis down")); + given(roleAuthorityService.getRoles(1L)).willReturn(List.of("USER")); filter.doFilter(requestWithToken(token), new MockHttpServletResponse(), new MockFilterChain()); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtProviderTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtProviderTest.java new file mode 100644 index 00000000..906034ce --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/JwtProviderTest.java @@ -0,0 +1,33 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.jsonwebtoken.Claims; + +/** + * JWT 발급·검증 계약을 검증한다. 특히 role은 더 이상 토큰에 담기지 않는다는 계약을 고정한다. + */ +class JwtProviderTest { + + private static final String TEST_SECRET = "test-secret-key-for-jwt-provider-unit-test"; + + private final JwtProvider jwtProvider = new JwtProvider(TEST_SECRET, 3600L); + + @Test + @DisplayName("토큰에는 roles claim이 없고, userId/sub/jti/expiration만 담긴다") + void generateToken_omitsRolesClaim() { + String token = jwtProvider.generateToken(1L, "user@test.com"); + + Claims claims = jwtProvider.getClaimsIfValid(token); + + assertThat(claims).isNotNull(); + assertThat(claims.get("roles")).isNull(); + assertThat(claims.get("userId", Long.class)).isEqualTo(1L); + assertThat(claims.getSubject()).isEqualTo("user@test.com"); + assertThat(claims.get("jti", String.class)).isNotBlank(); + assertThat(claims.getExpiration()).isAfter(claims.getIssuedAt()); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityServiceTest.java new file mode 100644 index 00000000..ae1f492a --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/RoleAuthorityServiceTest.java @@ -0,0 +1,75 @@ +package com.opensource.docgrid.domain.auth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; + +/** + * 인가 판단용 role을 Redis 캐시와 DB 폴백으로 조회하는 계약(캐시 히트/미스, 무효화, Redis 장애 대응)을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RoleAuthorityService 단위 테스트") +class RoleAuthorityServiceTest { + + @InjectMocks private RoleAuthorityService roleAuthorityService; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOperations; + @Mock private UserRoleRepository userRoleRepository; + + @Test + @DisplayName("캐시 히트: Redis에 값이 있으면 DB를 조회하지 않는다") + void getRoles_returnsCachedRoles_whenCacheHit() { + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:roles:1")).willReturn("USER,ADMIN"); + + List roles = roleAuthorityService.getRoles(1L); + + assertThat(roles).containsExactly("USER", "ADMIN"); + then(userRoleRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("캐시 미스: DB에서 조회한 뒤 Redis에 캐싱한다") + void getRoles_fetchesFromDbAndCaches_whenCacheMiss() { + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.get("auth:roles:1")).willReturn(null); + given(userRoleRepository.findRoleCodesByUserId(1L)).willReturn(List.of("USER")); + + List roles = roleAuthorityService.getRoles(1L); + + assertThat(roles).containsExactly("USER"); + then(valueOperations).should().set("auth:roles:1", "USER", java.time.Duration.ofSeconds(30)); + } + + @Test + @DisplayName("무효화 후에는 다시 DB를 조회한다") + void invalidate_removesCacheKey() { + roleAuthorityService.invalidate(1L); + + then(redisTemplate).should().delete("auth:roles:1"); + } + + @Test + @DisplayName("Redis 조회가 실패해도(장애) DB로 폴백해서 인증이 끊기지 않는다") + void getRoles_fallsBackToDb_whenRedisReadFails() { + given(redisTemplate.opsForValue()).willThrow(new RuntimeException("redis down")); + given(userRoleRepository.findRoleCodesByUserId(1L)).willReturn(List.of("USER")); + + List roles = roleAuthorityService.getRoles(1L); + + assertThat(roles).containsExactly("USER"); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java index 2d1aa96e..806ebef6 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/jwt/StompAuthChannelInterceptorTest.java @@ -31,13 +31,14 @@ class StompAuthChannelInterceptorTest { @Mock private JwtProvider jwtProvider; + @Mock private RoleAuthorityService roleAuthorityService; @Mock private MessageChannel channel; private StompAuthChannelInterceptor interceptor; @BeforeEach void setUp() { - interceptor = new StompAuthChannelInterceptor(jwtProvider); + interceptor = new StompAuthChannelInterceptor(jwtProvider, roleAuthorityService); } @Test @@ -47,8 +48,8 @@ void preSend_attachesPrincipal_whenTokenValid() { Claims claims = mock(Claims.class); given(claims.getSubject()).willReturn("admin@example.com"); given(claims.get("userId", Long.class)).willReturn(1L); - given(claims.get("roles")).willReturn(List.of("ADMIN")); given(jwtProvider.getClaimsIfValid("valid-token")).willReturn(claims); + given(roleAuthorityService.getRoles(1L)).willReturn(List.of("ADMIN")); Message connectMessage = connectMessage("Bearer valid-token"); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java index 2d022d00..c2332c9a 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/command/AuthCommandServiceTest.java @@ -159,7 +159,7 @@ void login_succeeds_when_validCredentials() { given(userRepository.findByEmail(AuthFixture.EMAIL)).willReturn(Optional.of(user)); given(passwordEncoder.matches(AuthFixture.PASSWORD, AuthFixture.PASSWORD_HASH)).willReturn(true); given(userRoleRepository.findRoleCodesByUserId(AuthFixture.USER_ID)).willReturn(List.of("USER")); - given(jwtProvider.generateToken(AuthFixture.USER_ID, AuthFixture.EMAIL, List.of("USER"))).willReturn("access-token"); + given(jwtProvider.generateToken(AuthFixture.USER_ID, AuthFixture.EMAIL)).willReturn("access-token"); given(jwtProvider.getExpirationSeconds()).willReturn(3600L); LoginResponse result = authCommandService.login(request); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/EmbeddingJobStatusChangedAfterCommitIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/EmbeddingJobStatusChangedAfterCommitIntegrationTest.java index 7dc1600d..95ba2e6b 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/EmbeddingJobStatusChangedAfterCommitIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/EmbeddingJobStatusChangedAfterCommitIntegrationTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.lang.reflect.Type; -import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -173,6 +172,6 @@ private String wsUrl() { } private String adminToken() { - return jwtProvider.generateToken(1L, "after-commit-admin@example.com", List.of("ADMIN")); + return jwtProvider.generateToken(1L, "after-commit-admin@example.com"); } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/websocket/DashboardWebSocketIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/websocket/DashboardWebSocketIntegrationTest.java index 48bc0813..89c9d880 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/websocket/DashboardWebSocketIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/websocket/DashboardWebSocketIntegrationTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.lang.reflect.Type; -import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.LinkedBlockingQueue; @@ -245,11 +244,11 @@ private String wsUrl() { } private String adminToken() { - return jwtProvider.generateToken(1L, "dashboard-admin@example.com", List.of("ADMIN")); + return jwtProvider.generateToken(1L, "dashboard-admin@example.com"); } private String userToken() { - return jwtProvider.generateToken(2L, "dashboard-user@example.com", List.of("USER")); + return jwtProvider.generateToken(2L, "dashboard-user@example.com"); } private DashboardSummaryResponse sampleSummary() { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java index 6320df27..e2842695 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java @@ -27,6 +27,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; @@ -116,6 +117,7 @@ class IndexingJobAdminControllerTest { @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private TokenBlacklistService tokenBlacklistService; + @MockitoBean private RoleAuthorityService roleAuthorityService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java index 2f651600..92ca225f 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java @@ -24,6 +24,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.document.service.DocumentParsingService; @@ -74,6 +75,7 @@ class IndexingJobAdminQueryControllerTest { @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private TokenBlacklistService tokenBlacklistService; + @MockitoBean private RoleAuthorityService roleAuthorityService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java index 7de28079..1a75063e 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/sync/controller/SyncAdminControllerTest.java @@ -25,6 +25,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.sync.dto.response.SyncAdminSummaryResponse; @@ -51,6 +52,7 @@ class SyncAdminControllerTest { @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private TokenBlacklistService tokenBlacklistService; + @MockitoBean private RoleAuthorityService roleAuthorityService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; diff --git a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java index ed7298cb..8fc95ecd 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/controller/AdminUserControllerTest.java @@ -2,6 +2,7 @@ import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -22,16 +23,20 @@ import org.springframework.web.cors.CorsConfigurationSource; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.domain.user.dto.request.ChangeDepartmentRequest; import com.opensource.docgrid.domain.user.dto.response.AdminUserResponse; +import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; import com.opensource.docgrid.domain.user.enums.UserStatus; import com.opensource.docgrid.domain.user.service.command.UserCommandService; import com.opensource.docgrid.domain.user.service.command.UserRoleCommandService; import com.opensource.docgrid.domain.user.service.query.AdminUserQueryService; import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; /** * 관리자 사용자 목록 API의 필터·Pagination·민감 정보 비노출과 ADMIN Security 계약을 검증한다. @@ -51,6 +56,7 @@ class AdminUserControllerTest { @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private TokenBlacklistService tokenBlacklistService; + @MockitoBean private RoleAuthorityService roleAuthorityService; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; @MockitoBean private CorsConfigurationSource corsConfigurationSource; @@ -155,4 +161,37 @@ void changeDepartment_returnsBadRequest_whenDepartmentIdIsMissing() throws Excep .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("COMMON-002")); } + + @Test + @DisplayName("ADMIN 사용자가 대상 사용자의 역할을 회수한다") + void revokeRole_returnsRemainingRoles() throws Exception { + UserRoleResponse response = new UserRoleResponse(10L, "hong@example.com", "홍길동", List.of("USER")); + given(userRoleCommandService.revokeRole(10L, "ADMIN")).willReturn(response); + + mockMvc.perform(delete(USERS_URL + "/{userId}/roles/{roleCode}", 10L, "ADMIN") + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").value(10)) + .andExpect(jsonPath("$.data.roles[0]").value("USER")); + } + + @Test + @DisplayName("ADMIN이 아닌 사용자는 역할을 회수할 수 없다") + void revokeRole_returnsForbidden_withoutAdminRole() throws Exception { + mockMvc.perform(delete(USERS_URL + "/{userId}/roles/{roleCode}", 10L, "ADMIN") + .with(user("user").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("부여되지 않은 역할을 회수하려 하면 404를 반환한다") + void revokeRole_returnsNotFound_whenRoleNotAssigned() throws Exception { + given(userRoleCommandService.revokeRole(10L, "ADMIN")) + .willThrow(new DocGridException(ErrorCode.ROLE_NOT_ASSIGNED)); + + mockMvc.perform(delete(USERS_URL + "/{userId}/roles/{roleCode}", 10L, "ADMIN") + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("ROLE-004")); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandServiceTest.java new file mode 100644 index 00000000..0fe0be76 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/user/service/command/UserRoleCommandServiceTest.java @@ -0,0 +1,134 @@ +package com.opensource.docgrid.domain.user.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.auth.fixture.AuthFixture; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; +import com.opensource.docgrid.domain.user.dto.request.AssignRoleRequest; +import com.opensource.docgrid.domain.user.dto.response.UserRoleResponse; +import com.opensource.docgrid.domain.user.entity.Department; +import com.opensource.docgrid.domain.user.entity.Role; +import com.opensource.docgrid.domain.user.entity.User; +import com.opensource.docgrid.domain.user.entity.UserRole; +import com.opensource.docgrid.domain.user.repository.RoleRepository; +import com.opensource.docgrid.domain.user.repository.UserRepository; +import com.opensource.docgrid.domain.user.repository.UserRoleRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 관리자의 역할 부여·회수 명령(assignRole/revokeRole)과 role 캐시 무효화 계약을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserRoleCommandService 단위 테스트") +class UserRoleCommandServiceTest { + + @InjectMocks private UserRoleCommandService userRoleCommandService; + @Mock private UserRepository userRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleRepository userRoleRepository; + @Mock private RoleAuthorityService roleAuthorityService; + + @Test + @DisplayName("정상 케이스: 관리자가 사용자에게 역할을 부여하면 캐시를 무효화한다") + void assignRole_success_invalidatesCache() { + Department department = AuthFixture.createDepartment(); + User targetUser = AuthFixture.createUser(department); + Role role = AuthFixture.createRole(); + + given(userRepository.findById(AuthFixture.USER_ID)).willReturn(Optional.of(targetUser)); + given(roleRepository.findByCode("USER")).willReturn(Optional.of(role)); + given(userRoleRepository.existsByUserIdAndRoleCode(AuthFixture.USER_ID, "USER")).willReturn(false); + given(userRepository.getReferenceById(99L)).willReturn(targetUser); + given(userRoleRepository.findAllWithRoleByUserId(AuthFixture.USER_ID)) + .willReturn(List.of(AuthFixture.createUserRole(targetUser, role))); + + UserRoleResponse result = userRoleCommandService.assignRole( + AuthFixture.USER_ID, 99L, new AssignRoleRequest("USER")); + + assertThat(result.roles()).containsExactly("USER"); + then(roleAuthorityService).should().invalidate(AuthFixture.USER_ID); + } + + @Test + @DisplayName("예외 케이스: 이미 부여된 역할이면 ROLE_ALREADY_ASSIGNED 예외가 발생한다") + void assignRole_throws_whenAlreadyAssigned() { + Department department = AuthFixture.createDepartment(); + User targetUser = AuthFixture.createUser(department); + Role role = AuthFixture.createRole(); + + given(userRepository.findById(AuthFixture.USER_ID)).willReturn(Optional.of(targetUser)); + given(roleRepository.findByCode("USER")).willReturn(Optional.of(role)); + given(userRoleRepository.existsByUserIdAndRoleCode(AuthFixture.USER_ID, "USER")).willReturn(true); + + assertThatThrownBy(() -> userRoleCommandService.assignRole( + AuthFixture.USER_ID, 99L, new AssignRoleRequest("USER"))) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_ALREADY_ASSIGNED); + + then(roleAuthorityService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("정상 케이스: 관리자가 부여된 역할을 회수하면 캐시를 무효화하고 남은 역할을 반환한다") + void revokeRole_success_invalidatesCache() { + Department department = AuthFixture.createDepartment(); + User targetUser = AuthFixture.createUser(department); + Role adminRole = AuthFixture.createRole(); + UserRole userRole = AuthFixture.createUserRole(targetUser, adminRole); + + given(userRepository.findById(AuthFixture.USER_ID)).willReturn(Optional.of(targetUser)); + given(userRoleRepository.findByUserIdAndRoleCode(AuthFixture.USER_ID, "ADMIN")) + .willReturn(Optional.of(userRole)); + given(userRoleRepository.findAllWithRoleByUserId(AuthFixture.USER_ID)).willReturn(List.of()); + + UserRoleResponse result = userRoleCommandService.revokeRole(AuthFixture.USER_ID, "ADMIN"); + + assertThat(result.roles()).isEmpty(); + then(userRoleRepository).should().delete(userRole); + then(roleAuthorityService).should().invalidate(AuthFixture.USER_ID); + } + + @Test + @DisplayName("예외 케이스: 대상 사용자가 없으면 USER_NOT_FOUND 예외가 발생한다") + void revokeRole_throws_whenUserNotFound() { + given(userRepository.findById(anyLong())).willReturn(Optional.empty()); + + assertThatThrownBy(() -> userRoleCommandService.revokeRole(999L, "ADMIN")) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + + then(roleAuthorityService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("예외 케이스: 부여되지 않은 역할을 회수하려 하면 ROLE_NOT_ASSIGNED 예외가 발생한다") + void revokeRole_throws_whenRoleNotAssigned() { + Department department = AuthFixture.createDepartment(); + User targetUser = AuthFixture.createUser(department); + + given(userRepository.findById(AuthFixture.USER_ID)).willReturn(Optional.of(targetUser)); + given(userRoleRepository.findByUserIdAndRoleCode(AuthFixture.USER_ID, "ADMIN")) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> userRoleCommandService.revokeRole(AuthFixture.USER_ID, "ADMIN")) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROLE_NOT_ASSIGNED); + + then(roleAuthorityService).shouldHaveNoInteractions(); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java index dc4b8857..9981ad90 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/worker/controller/WorkerAdminControllerTest.java @@ -24,6 +24,7 @@ import com.opensource.docgrid.domain.worker.fixture.WorkerNodeFixture; import com.opensource.docgrid.domain.worker.service.query.WorkerNodeQueryService; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; import com.opensource.docgrid.domain.auth.jwt.TokenBlacklistService; import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; import com.opensource.docgrid.global.config.SecurityConfig; @@ -50,6 +51,9 @@ class WorkerAdminControllerTest { @MockitoBean private TokenBlacklistService tokenBlacklistService; + @MockitoBean + private RoleAuthorityService roleAuthorityService; + @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; diff --git a/docs/design/kangcheolung-#226-role-revoke-redis-live-permission.md b/docs/design/kangcheolung-#226-role-revoke-redis-live-permission.md new file mode 100644 index 00000000..048fa171 --- /dev/null +++ b/docs/design/kangcheolung-#226-role-revoke-redis-live-permission.md @@ -0,0 +1,221 @@ +# #226 역할 회수 API + Redis 캐시 기반 권한 즉시 반영 + +closes #226 + +--- + +## 배경 + +QA 시나리오 2(관리자 사용자 관리) 진행 중 두 가지를 발견했다. + +**1) 역할을 되돌릴 방법이 없었다.** `UserRoleCommandService`엔 `assignRole()`만 있고 `revoke`가 없어서, 관리자가 역할을 잘못 부여해도 되돌릴 API가 어디에도 없었다. + +**2) 역할을 부여/회수해도 로그아웃 전까지 실제 권한이 안 바뀌었다.** `JwtAuthenticationFilter`/`StompAuthChannelInterceptor`가 `hasRole(...)` 인가를 로그인 시점 JWT에 박제된 `roles` claim(`JwtProvider.generateToken()`)만으로 판단했기 때문이다. `GET /auth/me`는 매 요청 DB를 새로 읽어 화면엔 바로 반영되는 것처럼 보이지만, 실제 `/admin/**` 등 Spring Security의 `hasRole` 판정은 토큰 발급 시점 값 그대로였다 — 실측으로 재현: 관리자가 이미 로그인해있던 사용자를 ADMIN으로 승격시키고 새로고침해도, 그 사용자는 `/admin/users` 같은 관리자 API를 호출하면 여전히 403이 났다. + +두 번째 문제가 **회수 쪽에서 더 심각하다** — 관리자가 잘못 준 ADMIN 권한을 회수해도, 대상자가 로그아웃하지 않는 한 계속 관리자 기능을 쓸 수 있는 보안 문제가 된다. 그래서 이 이슈는 역할 회수 API와 "재로그인 없이 즉시 반영" 인프라를 함께 다룬다. + +```text +관리자 → DELETE /admin/users/{userId}/roles/{roleCode} (JWT, ADMIN role) + → AdminUserController → UserRoleCommandService.revokeRole() + → DB에서 UserRole 삭제 + RoleAuthorityService 캐시 무효화 + → 대상 사용자는 로그아웃 없이 다음 요청부터 바로 권한 변경 반영 +``` + +--- + +## 설계 방향 — JWT는 신원만, role은 Redis+DB로 매 요청 조회 + +핵심 아이디어는 **JWT에서 인가(role) 판단 근거를 완전히 분리**하는 것이다. + +```text +지금까지: JwtAuthenticationFilter가 claims.get("roles")로 인가 판단 + → 로그인 시점에 박제된 값, DB가 바뀌어도 재로그인 전까진 그대로 + +바꾼 뒤: JwtAuthenticationFilter가 RoleAuthorityService.getRoles(userId) 호출 + → Redis 캐시(30초 TTL) 있으면 반환, 없으면 DB 조회 후 캐싱 + → assignRole()/revokeRole()이 DB 저장 직후 해당 유저 캐시를 즉시 delete + → 다음 요청부터 무조건 DB에서 재조회되어 반영됨 +``` + +JWT는 이제 `userId`/`email`(신원 확인)만 담당하고, `roles` claim은 완전히 제거했다. 인가 판단은 매 요청 `RoleAuthorityService`를 거친다. + +### 왜 Redis인가 — 새 인프라 없이 기존 것 재사용 + +이 프로젝트는 이미 로그아웃 시 JWT를 블랙리스트에 넣는 용도로 Redis(`StringRedisTemplate`, `TokenBlacklistService`)를 쓰고 있다. `RoleAuthorityService`도 같은 `StringRedisTemplate` 빈을 그대로 재사용해서, 신규 인프라 도입 없이 캐싱 레이어를 추가했다. + +### 캐시 설계 — 짧은 TTL + 능동 무효화의 조합 + +```java +private static final String KEY_PREFIX = "auth:roles:"; +private static final Duration TTL = Duration.ofSeconds(30); + +public List getRoles(Long userId) { + String cached = readCache(userId); + if (cached != null) { + return cached.isBlank() ? List.of() : Arrays.asList(cached.split(",")); + } + List roles = userRoleRepository.findRoleCodesByUserId(userId); + writeCache(userId, roles); + return roles; +} + +public void invalidate(Long userId) { + try { + redisTemplate.delete(KEY_PREFIX + userId); + } catch (Exception e) { + log.error("Redis role 캐시 무효화 실패, userId={}: {}", userId, e.getMessage()); + } +} +``` + +- **TTL 30초**는 안전망일 뿐, 실제 즉시 반영은 **능동 무효화**(`assignRole()`/`revokeRole()`이 DB 저장 직후 `invalidate()` 호출)로 만든다. TTL이 만료되기를 기다릴 필요 없이, role이 바뀐 바로 다음 요청부터 캐시 미스가 나서 DB를 재조회한다. +- 매 인증 요청마다 DB를 직접 때리지 않고 Redis를 우선 조회하므로, role 조회로 인한 DB 부하 증가를 최소화했다. +- 빈 role 목록(`""`)과 "캐시 없음"(`null`)을 구분해서, role이 0개인 사용자도 매번 DB를 다시 조회하지 않고 캐시로 처리한다. + +### Redis 장애에도 인증이 끊기면 안 된다 — TokenBlacklistService와 동일한 방어 패턴 + +`RoleAuthorityService`는 이제 **모든 인증된 요청의 critical path**에 있다. 기존 `TokenBlacklistService.isBlacklisted()`가 Redis 장애 시 예외를 던지지 않고 "블랙리스트 아님"으로 안전하게 폴백하는 것과 동일하게, `RoleAuthorityService`도 Redis 읽기/쓰기/삭제 전부를 `try/catch`로 감싸서 장애 시 DB 조회로 폴백하도록 했다. 이 방어 로직이 없었다면 Redis가 죽는 순간 전체 API가 500으로 막혔을 것이다 — 구현 중간에 발견해서 추가했다. + +```java +private String readCache(Long userId) { + try { + return redisTemplate.opsForValue().get(KEY_PREFIX + userId); + } catch (Exception e) { + log.error("Redis role 캐시 조회 실패, DB로 폴백합니다. userId={}: {}", userId, e.getMessage()); + return null; + } +} +``` + +--- + +## 신규/변경 파일 + +### 1. 신규 — `domain/auth/jwt/RoleAuthorityService.java` + +`TokenBlacklistService`와 같은 패키지·스타일. `getRoles(Long userId)` / `invalidate(Long userId)` 두 개의 공개 메서드만 가진다. + +### 2. `JwtProvider.java` — `roles`를 더 이상 토큰에 담지 않음 + +```java +// Before +public String generateToken(Long userId, String email, List roles) { + ... + .claim("roles", roles) + ... +} + +// After +public String generateToken(Long userId, String email) { + ... + // roles claim 제거 +} +``` + +### 3. `JwtAuthenticationFilter.java` / `StompAuthChannelInterceptor.java` — 인가 소스 교체 + +```java +// Before +List roles = (List) claims.get("roles"); + +// After +List roles = roleAuthorityService.getRoles(userId); +``` + +`JwtAuthenticationFilter`는 `SecurityConfig`에서 `new`로 직접 생성되는 필터라 생성자에 `RoleAuthorityService`를 3번째 인자로 추가했고, `StompAuthChannelInterceptor`는 스프링 빈이라 필드 추가만으로 자동 주입됐다. + +**주의(이슈 To-do엔 없었지만 필수 종속 변경)**: JWT에서 `roles` claim을 제거하면 `StompAuthChannelInterceptor`의 `claims.get("roles")`가 `null`을 반환해 `.stream()` 호출 시 NPE가 난다. WebSocket 인증 경로도 같이 고쳐야만 컴파일·런타임 모두 안전했다. + +### 4. `SecurityConfig.java` + +`RoleAuthorityService`를 주입받아 `new JwtAuthenticationFilter(jwtProvider, tokenBlacklistService, roleAuthorityService)`로 변경. + +### 5. `AuthCommandService.login()` + +`jwtProvider.generateToken(user.getId(), user.getEmail())` (roles 인자 제거). `LoginResponse`에 담기는 `roles`는 원래부터 `userRoleRepository.findRoleCodesByUserId()`로 별도 조회하던 값이라 변경 없음 — JWT 토큰 내부와 로그인 응답 바디는 애초에 다른 값이었다. + +### 6. 역할 회수 API + +- `ErrorCode`에 `ROLE_NOT_ASSIGNED(HttpStatus.NOT_FOUND, "ROLE-004", "부여되지 않은 역할입니다.")` 추가 +- `UserRoleRepository`에 `Optional findByUserIdAndRoleCode(Long userId, String roleCode)` 추가 +- `UserRoleCommandService.revokeRole(Long targetUserId, String roleCode)`: + 1. 대상 사용자 조회 → 없으면 `USER_NOT_FOUND` + 2. `findByUserIdAndRoleCode`로 부여 기록 조회 → 없으면 `ROLE_NOT_ASSIGNED` + 3. `userRoleRepository.delete(userRole)` + 4. `roleAuthorityService.invalidate(targetUserId)` — 캐시 무효화, 다음 요청부터 즉시 반영 + 5. 남은 역할을 재조회해 `UserRoleResponse`로 반환 (`assignRole()`과 대칭 구조) +- `assignRole()` 끝에도 동일하게 `roleAuthorityService.invalidate(targetUserId)`를 추가했다 (이전엔 캐시 개념 자체가 없어서 무효화 호출이 없었다). +- `AdminUserController`에 `DELETE /admin/users/{userId}/roles/{roleCode}` 추가. 기존 `/admin/**` → `hasRole("ADMIN")` 규칙이 그대로 적용된다. + +--- + +## API 명세 + +### DELETE /admin/users/{userId}/roles/{roleCode} — 역할 회수 + +```text +Authorization: Bearer {JWT, ADMIN role} +``` + +```json +// 200 OK +{ + "success": true, + "status": 200, + "data": { + "userId": 10, + "email": "hong@example.com", + "name": "홍길동", + "roles": ["USER"] + } +} +``` + +### 에러 케이스 + +| 상황 | 코드 | HTTP | +| --- | --- | --- | +| ADMIN 권한 없이 호출 | 없음 (Spring Security 필터에서 차단) | 403 | +| 존재하지 않는 `userId` | `USER-001` (`USER_NOT_FOUND`) | 404 | +| 부여되지 않은 역할 회수 시도 | `ROLE-004` (`ROLE_NOT_ASSIGNED`) | 404 | + +--- + +## 프론트엔드 + +`/admin/users` 페이지의 사용자 목록에서 각 역할 칩(pill) 옆에 회수(×) 버튼을 추가했다(`AdminPages.tsx`). 클릭 시 `window.confirm`으로 확인 후 `DELETE /admin/users/{userId}/roles/{roleCode}` 호출 → 성공하면 토스트 알림 + 목록 새로고침. 기존 문서/컬렉션 삭제 액션(`CollectionsPage.tsx`, `DocumentsPage.tsx`)과 동일하게 `window.confirm` 패턴을 재사용했다. 회수 중인 칩만 개별적으로 비활성화되도록 `revokingKey` state로 `"{userId}:{roleCode}"` 단위 잠금 처리를 했다. + +--- + +## 검증 + +- `./backend/gradlew -p backend test --tests "com.opensource.docgrid.domain.auth.*" --tests "com.opensource.docgrid.domain.user.*" --tests "...IndexingJobAdminControllerTest" --tests "...IndexingJobAdminQueryControllerTest" --tests "...SyncAdminControllerTest" --tests "...WorkerAdminControllerTest"`: 전부 통과 + - 신규: `RoleAuthorityServiceTest`(캐시 히트/미스/무효화/Redis 장애 폴백 4건), `UserRoleCommandServiceTest`(부여/회수 성공·실패 5건), `AdminUserControllerTest` DELETE 케이스 3건 + - 기존 테스트 중 JWT 시그니처 변경으로 깨졌던 것 전부 수정: `JwtAuthenticationFilterTest`, `StompAuthChannelInterceptorTest`, `AuthCommandServiceTest` + - `@Import(SecurityConfig.class)`를 쓰는 `@WebMvcTest` 5곳(`AdminUserControllerTest`, `IndexingJobAdminControllerTest`, `IndexingJobAdminQueryControllerTest`, `SyncAdminControllerTest`, `WorkerAdminControllerTest`)에 `RoleAuthorityService` 신규 의존성이 추가되면서 컨텍스트 로딩이 깨져(`NoSuchBeanDefinitionException`) 전부 `@MockitoBean` 추가로 해결 — `#96`(MCP 인증 인프라) 이슈에서 `McpAccessTokenCommandService` 추가 때 겪은 것과 동일한 패턴. +- `./backend/gradlew -p backend test`(필터 없는 전체 983개) 1회 실행: 이번 변경과 무관한 기존 결함 2건만 실패(`UserRepositoryTest`, `RagJobWorkerConcurrentQueueIntegrationTest`) — 둘 다 근본 원인까지 확인 완료(아래 "부수적으로 발견한 것" 참고), 이번 PR 범위 밖이라 손대지 않음 +- 프론트: `npx tsc --noEmit` — `AdminPages.tsx`/`globals.css` 관련 타입 에러 없음 +- 실제 시나리오 재현 확인(로컬): 관리자가 이미 로그인해있던 사용자를 ADMIN으로 승격 → 재로그인 없이 새 요청부터 `/admin/**` 정상 접근 확인 → 회수 → 재로그인 없이 즉시 403으로 돌아가는지 확인 (QA에서 처음 겪었던 그 시나리오가 해결됨을 실제로 검증) + +### 부수적으로 발견한 것 (이번 PR 범위 밖, 기록만) + +- **`build.gradle`의 `excludeTags`에 `integration`이 빠져 있다.** `@Tag("integration")`인 테스트 33개가 `testing_guide.md`가 문서화한 것과 달리 기본 `./gradlew test`에 항상 섞여 돈다. `-Dgroups=integration`으로 분리 실행한다는 문서 내용도 실제로는 `build.gradle`에 그 시스템 프로퍼티를 읽는 태스크가 없어 작동하지 않는 상태였다. `localE2eTest`/`minioIntegrationTest` 같은 기존 `includeTags` 전용 태스크 패턴을 그대로 따라 `integrationTest` 태스크를 신설하고 기본 `test`의 `excludeTags`에 `integration`을 추가하면 해결되지만, 33개 클래스의 기본 실행 여부를 바꾸는 저장소 전체 정책 변경이라 이 PR에 묶지 않았다. +- **`UserRepositoryTest`가 스위트 전체를 돌릴 때 간헐적으로 실패하는 근본 원인을 확인했다.** `docgrid_test` 스키마에 과거 integration 테스트들이 커밋하고 정리하지 않은 row가 42개 쌓여 있고, 정작 이 테스트의 쿼리 호출(`PageRequest.of(0, 20)`)엔 정렬(`Sort`) 지정이 없다. 그래서 "1페이지 20개"가 어떤 20개인지 PostgreSQL이 보장해주지 않고, 방금 만든 테스트 row가 그 안에 든다는 보장이 없다. 코드 결함이 아니라 테스트 설계 결함(정렬 없는 페이지 조회 + 스키마 미정리) 두 가지가 겹친 것으로, 이 PR과 무관해 손대지 않았다. +- `/admin/**` 미인증(403) 응답이 Spring Security 기본 포맷이라 프론트가 에러 메시지를 못 뽑아내 "요청을 처리하지 못했습니다"로만 뭉뚱그려 보이는 문제 — 필요시 별도 이슈. + +--- + +## 남은 이슈 / TODO + +- 부서(department)도 사람이 승격/강등되듯 바뀌는 값인데, 이번 이슈에서 손댄 role 캐싱과 달리 department 기반 권한(`DocumentPermission`/`CollectionPermission`, `targetType=DEPARTMENT`)은 원래부터 매 요청 live JOIN으로 판단해 캐시가 없다 — 이번 즉시반영 작업과 정합성 문제 없음, 별도 조치 불필요함을 확인. +- 위 "부수적으로 발견한 것" 3건은 각각 독립적인 후속 이슈 후보. + +### 알려진 한계 (CodeRabbit 리뷰에서 지적, 의도적으로 이번 PR에서 해결하지 않음) + +캐시 무효화를 트랜잭션 커밋 이후로 미루는 수정(`invalidateAfterCommit`)으로 가장 흔한 시나리오는 해결했지만, 아래 세 가지는 여전히 남아있는 엣지 케이스다. 셋 다 "정합성을 100% 보장하려면 상당한 설계 변경이 필요한데, 실제 발생 확률과 영향 범위에 비해 비용이 큰" 케이스라 의도적으로 남겨뒀다. + +1. **캐시 미스 재경쟁**: 요청 A가 캐시 미스로 DB에서 role을 읽는 도중(요청이 오래 걸리는 경우), 그 사이 요청 B가 회수+커밋+무효화를 전부 끝내고, 이후 A가 자신이 읽은 옛날 값을 캐시에 쓰면 그 값이 다시 TTL(30초)만큼 살아난다. `invalidateAfterCommit`이 가장 흔한 형태(무효화가 커밋보다 먼저 나가는 것)는 막았지만, 이 순서 자체의 재경쟁까지 막으려면 캐시 값에 사용자별 버전/세대 번호를 같이 저장하고 쓰기 전에 검증하는 구조가 필요하다 — 이번 PR 범위를 넘어서는 설계 변경이라 남겨둔다. +2. **Redis 삭제 자체가 실패하는 경우**: `invalidate()`는 Redis 장애 시 로그만 남기고 넘어가도록 의도적으로 설계했다(가용성 우선, `RoleAuthorityService`의 "Redis 장애에도 인증이 끊기면 안 된다" 참고). 그 순간 무효화가 실제로 안 먹히면, Redis가 복구돼도 TTL이 끝날 때까지 옛날 role이 남을 수 있다. 정합성을 완전히 지키려면 Redis 장애 시 "이 사용자는 캐시 신뢰 불가" 마커를 다른 저장소(예: DB)에 남기는 식의 이중화가 필요한데, 이 역시 이번 PR 범위 밖이다. +3. **이미 연결된 WebSocket(대시보드) 세션**: `StompAuthChannelInterceptor`는 STOMP CONNECT 시점에만 권한을 확인해서 세션에 붙여두고, 이후 프레임에서는 재검증하지 않는다. 따라서 역할을 회수해도 이미 연결돼 있던 대시보드 WebSocket 세션은 재연결 전까지 예전 권한으로 계속 동작한다. 완전히 고치려면 역할 변경 시 대상자의 기존 세션을 강제 종료하거나 프레임마다 재검증하는 구조가 필요하며, 이번 이슈의 핵심 범위(HTTP `/admin/**`)를 넘어서는 별도 작업이다. + +세 가지 다 별도 이슈로 분리할 수 있는 후보다. diff --git a/frontend/app/features/AdminPages.tsx b/frontend/app/features/AdminPages.tsx index b5cb151e..7269b69f 100644 --- a/frontend/app/features/AdminPages.tsx +++ b/frontend/app/features/AdminPages.tsx @@ -246,6 +246,7 @@ export function AdminUsersPage({ notify }: { notify: (message: string) => void } const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [deptBusy, setDeptBusy] = useState(false); + const [revokingKey, setRevokingKey] = useState(""); const [error, setError] = useState(""); const load = useCallback(async () => { @@ -282,6 +283,19 @@ export function AdminUsersPage({ notify }: { notify: (message: string) => void } finally { setBusy(false); } } + async function revokeRole(userId: number, roleCode: string) { + if (!window.confirm(`이 사용자의 ${roleCode} 역할을 회수할까요?`)) return; + const key = `${userId}:${roleCode}`; + setRevokingKey(key); setError(""); + try { + const response = await apiRequest(`/admin/users/${userId}/roles/${roleCode}`, { method: "DELETE" }); + notify(`${response.name} 사용자의 ${roleCode} 역할을 회수했습니다.`); + // Refresh role chips in the list after the command succeeds. + await load(); + } catch (reason) { setError(errorMessage(reason)); } + finally { setRevokingKey(""); } + } + async function changeDepartment(event: FormEvent) { event.preventDefault(); setDeptBusy(true); setError(""); @@ -322,7 +336,7 @@ export function AdminUsersPage({ notify }: { notify: (message: string) => void } {loading ? : null} {!loading && data && !data.content.length ? : null} {!loading && data?.content.length ? <> -

사용자부서역할상태
{data.content.map((user) =>
{user.name}#{user.userId} · {user.email}
가입 {formatDate(user.createdAt, false)}
{user.departmentName ?? "미지정"}{user.roles.map((role) => )}
)}
+
사용자부서역할상태
{data.content.map((user) =>
{user.name}#{user.userId} · {user.email}
가입 {formatDate(user.createdAt, false)}
{user.departmentName ?? "미지정"}{user.roles.map((role) => )}
)}
{data.totalElements}명 · {data.size}명씩
: null} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8b1236ac..3281fbfe 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -311,6 +311,11 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .permission-layout { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: .72fr 1.28fr; gap: 14px; } .permission-summary, .permission-list { margin: 0; } .permission-checks, .source-chips, .role-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.role-chip-revoke { display: inline-flex; align-items: center; gap: 2px; } +.role-chip-revoke button { border: none; background: none; color: #9b9ca8; cursor: pointer; font-size: 10px; line-height: 1; padding: 0 2px; } +.role-chip-revoke button:hover { color: #e05252; } +.role-chip-revoke button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.role-chip-revoke button:disabled { color: #cfd0d8; cursor: not-allowed; } .permission-checks { margin: 20px 0; } .field-label { display: block; margin-bottom: 8px; color: #8a8b99; font-size: 8px; } .source-chips b { padding: 4px 7px; color: #6256d8; background: var(--violet-soft); border-radius: 6px; font-size: 7px; }