diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java new file mode 100644 index 00000000..0bf20aab --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java @@ -0,0 +1,63 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 기존 대시보드 구독에 대한 실제 outbound MESSAGE 전송을 현재 primary 역할로 제한한다. + * + *

SUBSCRIBE 이후 역할이 회수되어도 브로커에는 구독이 남을 수 있다. 이 경계는 각 물리 세션에 + * 전달할 때 다시 확인하며, 역할 조회가 불가능하거나 ADMIN이 아니면 메시지를 버리고 세션을 닫는다. + * RAG 개인 알림과 연결 제어 프레임은 이 검사 대상이 아니다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class StompDashboardOutboundAuthorizationInterceptor implements ChannelInterceptor { + + private static final String DASHBOARD_TOPIC = "/topic/dashboard"; + + private final StompSessionRegistry stompSessionRegistry; + private final RoleAuthorityService roleAuthorityService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + // 1. 브로커가 구독자에게 보내는 대시보드 MESSAGE만 검사한다. + if (!SimpMessageType.MESSAGE.equals(SimpMessageHeaderAccessor.getMessageType(message.getHeaders())) + || !DASHBOARD_TOPIC.equals(SimpMessageHeaderAccessor.getDestination(message.getHeaders()))) { + return message; + } + + // 2. 실제 수신 세션의 인증 snapshot이 없거나 CONNECT 당시 ADMIN이 아니면 버린다. + String sessionId = SimpMessageHeaderAccessor.getSessionId(message.getHeaders()); + StompSessionAuthorization authorization = stompSessionRegistry.authorizationFor(sessionId); + if (authorization == null || !authorization.roles().contains("ADMIN")) { + if (sessionId != null) { + stompSessionRegistry.close(sessionId); + } + return null; + } + + // 3. 현재 primary의 역할을 확인한다. 조회 실패도 이전 ADMIN snapshot으로 우회하지 않는다. + try { + if (roleAuthorityService.getRolesForAdmin(authorization.userId()).contains("ADMIN")) { + return message; + } + } catch (RuntimeException exception) { + log.warn("STOMP 대시보드 전송의 최신 역할을 확인할 수 없어 차단합니다: {}", exception.getMessage()); + } + + // 4. 회수되었거나 확인할 수 없는 세션은 메시지를 버리고 다음 전송도 받지 않도록 닫는다. + stompSessionRegistry.close(sessionId); + return null; + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptor.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptor.java index 97edf01e..90bf5644 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptor.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptor.java @@ -14,12 +14,18 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.stereotype.Component; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + /** * STOMP client의 SUBSCRIBE·SEND 목적지를 명시적인 허용 목록으로 제한한다. * - *

{@code StompAuthChannelInterceptor}가 CONNECT 시점에 세션에 부착한 Principal을 재사용해 - * 목적지 접근 시점에 다시 검증한다. {@code /topic/dashboard}는 ADMIN만, 사용자별 RAG 완료 알림인 - * {@code /user/queue/rag-answer}는 인증된 사용자만 구독할 수 있다. 그 외 정확한 목적지와 pattern, + *

{@code StompAuthChannelInterceptor}가 CONNECT 시점에 세션에 부착한 Principal을 확인하고, + * 관리자 구독은 현재 primary의 역할도 다시 조회한다. {@code /topic/dashboard}는 ADMIN만, + * 사용자별 RAG 완료 알림인 {@code /user/queue/rag-answer}는 인증된 사용자만 구독할 수 있다. + * 그 외 정확한 목적지와 pattern, * Spring이 내부에서 만드는 실제 {@code /queue} 목적지는 모두 거부한다. * *

애플리케이션에는 client가 호출할 {@code @MessageMapping}이 없고 실제 push는 서버의 @@ -32,6 +38,8 @@ * {@code MissingCsrfTokenException}으로 거부되므로, 그 DSL 대신 이 수동 Interceptor로 구현한다. */ @Component +@RequiredArgsConstructor +@Slf4j public class StompDestinationAuthorizationInterceptor implements ChannelInterceptor { private static final String DASHBOARD_TOPIC = "/topic/dashboard"; @@ -40,6 +48,8 @@ public class StompDestinationAuthorizationInterceptor implements ChannelIntercep private static final String SUBSCRIPTION_DENIED_MESSAGE = "구독 권한이 없습니다."; private static final String SEND_DENIED_MESSAGE = "메시지를 보낼 권한이 없습니다."; + private final RoleAuthorityService roleAuthorityService; + @Override public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); @@ -59,7 +69,7 @@ public Message preSend(Message message, MessageChannel channel) { // 3. 넓은 pattern 대신 프런트가 실제 사용하는 두 목적지만 정확히 일치할 때 허용한다. String destination = accessor.getDestination(); - if (DASHBOARD_TOPIC.equals(destination) && isAdmin(accessor.getUser())) { + if (DASHBOARD_TOPIC.equals(destination) && isCurrentAdmin(accessor.getUser())) { return message; } if (RAG_ANSWER_QUEUE.equals(destination) && isAuthenticated(accessor.getUser())) { @@ -70,13 +80,24 @@ public Message preSend(Message message, MessageChannel channel) { throw new AccessDeniedException(SUBSCRIPTION_DENIED_MESSAGE); } - private boolean isAdmin(Principal user) { + private boolean isCurrentAdmin(Principal user) { if (!(user instanceof Authentication authentication)) { return false; } - return authentication.isAuthenticated() && authentication.getAuthorities().stream() + // CONNECT 때의 ADMIN snapshot만으로는 역할 회수 뒤 새 구독을 허용할 수 없다. + boolean wasAdmin = authentication.isAuthenticated() && authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .anyMatch(ADMIN_AUTHORITY::equals); + if (!wasAdmin || !(authentication.getDetails() instanceof Long userId)) { + return false; + } + try { + return roleAuthorityService.getRolesForAdmin(userId).contains("ADMIN"); + } catch (RuntimeException exception) { + // primary 상태를 확인하지 못하면 저장된 권한으로 폴백하지 않는다. + log.warn("STOMP 관리자 구독의 최신 역할을 확인할 수 없어 거부합니다: {}", exception.getMessage()); + return false; + } } private boolean isAuthenticated(Principal user) { diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java index 30d0357b..1c906eb9 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistry.java @@ -16,7 +16,8 @@ * 현재 Backend 인스턴스가 소유한 물리 WebSocket 연결과 STOMP 인증 snapshot을 함께 관리한다. * *

물리 연결은 WebSocket decorator가 먼저 등록하고, CONNECT 인증이 끝난 뒤 같은 sessionId에 - * 인증 snapshot을 결합한다. 주기 검사는 인증 완료 세션만 읽으며, 종료와 인증이 경합해도 하나의 + * 인증 snapshot을 결합한다. 주기 검사와 outbound 인가는 인증 완료 세션만 읽으며, + * 종료와 인증이 경합해도 하나의 * ConcurrentMap entry를 기준으로 정리해 닫힌 연결이 다시 등록되는 것을 막는다. * *

이 registry는 로컬 전송 자원만 관리한다. 여러 Backend 인스턴스는 각자 자신의 registry를 @@ -69,6 +70,15 @@ public int authenticatedSessionCount() { .count(); } + /** outbound 전송 대상의 열린 물리 세션에 결합된 CONNECT 인증 snapshot을 조회한다. */ + public StompSessionAuthorization authorizationFor(String sessionId) { + if (sessionId == null) { + return null; + } + SessionState state = sessions.get(sessionId); + return state != null && state.session().isOpen() ? state.authorization() : null; + } + public boolean close(String sessionId) { SessionState state = sessions.get(sessionId); if (state == null) { diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java index e7f7c7de..76e76e30 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/WebSocketConfig.java @@ -10,6 +10,7 @@ import com.opensource.docgrid.domain.auth.jwt.StompAuthChannelInterceptor; import com.opensource.docgrid.domain.auth.websocket.StompDestinationAuthorizationInterceptor; +import com.opensource.docgrid.domain.auth.websocket.StompDashboardOutboundAuthorizationInterceptor; import com.opensource.docgrid.domain.auth.websocket.StompSessionTrackingDecoratorFactory; import lombok.RequiredArgsConstructor; @@ -19,8 +20,9 @@ * *

인증·인가는 이 설정이 아니라 {@link StompAuthChannelInterceptor}(CONNECT 시점 인증)와 * {@link StompDestinationAuthorizationInterceptor}(SUBSCRIBE·SEND 시점 인가)가 담당한다. - * 이 클래스는 전송 계층 구성(endpoint·broker·origin), 물리 세션 추적과 두 Interceptor의 등록 순서만 - * 책임진다. + * 기존 구독에 나가는 대시보드 메시지는 {@link StompDashboardOutboundAuthorizationInterceptor}가 + * 현재 역할을 다시 확인한다. 이 클래스는 전송 계층 구성(endpoint·broker·origin), 물리 세션 추적과 + * Interceptor 등록만 책임진다. * *

{@code /queue}는 RAG 답변 개인 알림({@code convertAndSendToUser})의 broker 내부 목적지로 쓰인다. * client는 {@code /user/queue/rag-answer}만 구독할 수 있고, 실제 {@code /queue}와 pattern 접근은 @@ -33,6 +35,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final StompAuthChannelInterceptor stompAuthChannelInterceptor; private final StompDestinationAuthorizationInterceptor stompDestinationAuthorizationInterceptor; + private final StompDashboardOutboundAuthorizationInterceptor stompDashboardOutboundAuthorizationInterceptor; private final StompSessionTrackingDecoratorFactory stompSessionTrackingDecoratorFactory; @Override @@ -60,4 +63,10 @@ public void configureClientInboundChannel(ChannelRegistration registration) { // 순서가 바뀌면 2번 시점에 Principal이 아직 없어 항상 거부된다. registration.interceptors(stompAuthChannelInterceptor, stompDestinationAuthorizationInterceptor); } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + // 브로커가 기존 구독자에게 보내는 매 메시지는 실제 전달 전에 최신 관리자 역할을 확인한다. + registration.interceptors(stompDashboardOutboundAuthorizationInterceptor); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java index 5fa7b480..dff7ff98 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java @@ -42,7 +42,6 @@ import org.springframework.web.socket.messaging.WebSocketStompClient; import com.opensource.docgrid.domain.auth.jwt.JwtProvider; -import com.opensource.docgrid.domain.auth.websocket.StompSessionRevalidationScheduler; import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController; import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; import com.opensource.docgrid.domain.dashboard.dto.response.DocumentsSummaryResponse; @@ -57,10 +56,10 @@ import com.opensource.docgrid.domain.user.repository.UserRoleRepository; /** - * 실제 HTTP 역할 회수, PostgreSQL 커밋, Redis 캐시 무효화와 STOMP 세션 재검증을 연결한다. + * 실제 HTTP 역할 회수, PostgreSQL 커밋, Redis 캐시 무효화와 STOMP 전송 인가를 연결한다. * - *

테스트 전용 사용자만 생성·삭제하며, 자동 재검증을 늦추고 직접 호출해 회수 직후의 - * 기존 구독 창과 재검증 후 종료를 구분한다. GCP 복제본 라우팅은 이 로컬 테스트의 범위가 아니다. + *

테스트 전용 사용자만 생성·삭제하며, 자동 재검증을 늦춘 채 회수 직후의 + * 새 구독과 기존 구독 push가 차단되는지 확인한다. GCP 복제본 라우팅은 범위 밖이다. */ @Tag("integration") @ActiveProfiles("test") @@ -96,16 +95,15 @@ class StompDashboardRealRoleRevocationIntegrationTest { @Autowired private SimpUserRegistry simpUserRegistry; - @Autowired - private StompSessionRevalidationScheduler revalidationScheduler; - @Autowired private DashboardWebSocketController dashboardWebSocketController; private WebSocketStompClient stompClient; private User adminCaller; private User targetUser; + private StompSession adminSession; private StompSession oldSession; + private StompSession oldIdleSession; private StompSession newSession; @DynamicPropertySource @@ -126,21 +124,25 @@ void setUp() { void tearDown() { // 1. 세션을 먼저 닫고 이 테스트가 만든 사용자·역할과 Redis 키만 제거한다. disconnect(newSession); + disconnect(oldIdleSession); disconnect(oldSession); + disconnect(adminSession); if (targetUser != null) { redisTemplate.delete(roleCacheKey(targetUser.getId())); redisTemplate.delete(roleEpochKey(targetUser.getId())); deleteTestUser(targetUser.getId()); } if (adminCaller != null) { + redisTemplate.delete(roleCacheKey(adminCaller.getId())); + redisTemplate.delete(roleEpochKey(adminCaller.getId())); deleteTestUser(adminCaller.getId()); } stompClient.stop(); } @Test - @DisplayName("HTTP 역할 회수 커밋이 Redis 캐시와 새·기존 관리자 WebSocket 세션에 반영된다") - void revokesRealAdminRole_andRevalidatesDashboardSessions() throws Exception { + @DisplayName("HTTP 역할 회수 뒤 주기 재검증 없이 새 구독과 기존 구독 push가 차단된다") + void revokesRealAdminRole_andBlocksDashboardSessionsBeforeScheduledRevalidation() throws Exception { // 1. seed 역할은 재사용하되 사용자와 매핑은 이 테스트만 소유한다. Role adminRole = roleRepository.findByCode("ADMIN").orElseThrow(); adminCaller = createTestUser("caller"); @@ -156,10 +158,21 @@ void revokesRealAdminRole_andRevalidatesDashboardSessions() throws Exception { // 2. 실제 STOMP CONNECT와 SUBSCRIBE가 DB 역할을 Redis에 캐시하고 브로커에 등록한다. BlockingQueue received = new LinkedBlockingQueue<>(); + BlockingQueue activeReceived = new LinkedBlockingQueue<>(); oldSession = connect(targetToken, new LinkedBlockingQueue<>()); oldSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(received)); + adminSession = connect( + jwtProvider.generateToken(adminCaller.getId(), adminCaller.getEmail()), + new LinkedBlockingQueue<>() + ); + adminSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(activeReceived)); await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) - .until(() -> hasDashboardSubscription(targetEmail)); + .until(() -> hasDashboardSubscription(targetEmail) && hasDashboardSubscription(adminCaller.getEmail())); + dashboardWebSocketController.sendDashboardUpdate(sampleSummary(1L)); + assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + assertThat(activeReceived.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + BlockingQueue oldIdleFailures = new LinkedBlockingQueue<>(); + oldIdleSession = connect(targetToken, oldIdleFailures); assertThat(redisTemplate.opsForValue().get(cacheKey)).contains("ADMIN"); // 3. 실제 관리자 HTTP 요청이 DB 트랜잭션을 커밋한 뒤 Redis 캐시를 무효화한다. @@ -176,24 +189,26 @@ void revokesRealAdminRole_andRevalidatesDashboardSessions() throws Exception { assertThat(redisTemplate.opsForValue().get(cacheKey)).isNull(); assertThat(redisTemplate.opsForValue().get(epochKey)).isEqualTo("1"); - // 4. 주기 검사 전 옛 구독에는 push가 도달하지만 새 연결의 관리자 구독은 거부된다. - dashboardWebSocketController.sendDashboardUpdate(sampleSummary(1L)); - assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + // 실제 무효화 결과를 확인한 뒤 의도적으로 옛 ADMIN 캐시를 넣어도 관리자 경계는 primary만 신뢰한다. + redisTemplate.opsForValue().set(cacheKey, "ADMIN", Duration.ofSeconds(30)); + + // 4. 회수 뒤 기존 연결의 새 SUBSCRIBE와 기존 구독의 새 push가 모두 차단된다. + oldIdleSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>())); + assertThat(oldIdleFailures.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + dashboardWebSocketController.sendDashboardUpdate(sampleSummary(2L)); + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> !oldSession.isConnected()); + assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); + assertThat(activeReceived.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + + // 5. 새 CONNECT도 회수된 관리자 구독을 만들 수 없고 scheduler를 호출할 필요가 없다. BlockingQueue newFailures = new LinkedBlockingQueue<>(); newSession = connect(targetToken, newFailures); newSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>())); assertThat(newFailures.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) .until(() -> !newSession.isConnected()); - assertThat(redisTemplate.opsForValue().get(cacheKey)).doesNotContain("ADMIN"); - - // 5. 실제 DB를 읽는 재검증 뒤 옛 세션과 구독이 제거되고 새 push는 전달되지 않는다. - revalidationScheduler.revalidate(); - await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) - .until(() -> simpUserRegistry.getUser(targetEmail) == null); - assertThat(oldSession.isConnected()).isFalse(); - dashboardWebSocketController.sendDashboardUpdate(sampleSummary(2L)); - assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); + assertThat(redisTemplate.opsForValue().get(cacheKey)).isEqualTo("ADMIN"); } private User createTestUser(String kind) { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java index a3abe315..5a576232 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java @@ -2,9 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.mock; import java.lang.reflect.Type; import java.time.Duration; @@ -40,23 +38,18 @@ import com.opensource.docgrid.domain.auth.jwt.JwtProvider; import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; -import com.opensource.docgrid.domain.auth.websocket.StompSessionRevalidationScheduler; import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController; import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; import com.opensource.docgrid.domain.dashboard.dto.response.DocumentsSummaryResponse; import com.opensource.docgrid.domain.dashboard.dto.response.JobsSummaryResponse; import com.opensource.docgrid.domain.dashboard.dto.response.SearchSummaryResponse; import com.opensource.docgrid.domain.dashboard.dto.response.WorkersSummaryResponse; -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.UserRoleRepository; /** * 실제 WebSocket의 관리자 권한 회수 전후 경계를 신규 연결·새 구독·기존 구독으로 나눠 검증한다. * - *

주기 검사를 자동 실행하지 않고 직접 호출해 검사 전 허용 창과 검사 후 물리 세션 종료를 - * 결정적으로 구분한다. 이 테스트는 즉시 회수를 구현하지 않으며 현재 제품 계약을 기록한다. + *

주기 검사를 늦춘 상태에서 새 구독과 기존 구독 push를 각각 검사해, + * 회수 직후의 관리자 데이터 차단이 scheduler 없이도 동작하는지 확인한다. */ @Tag("integration") @ActiveProfiles("test") @@ -78,18 +71,12 @@ class StompDashboardRoleRevocationIntegrationTest { @Autowired private SimpUserRegistry simpUserRegistry; - @Autowired - private StompSessionRevalidationScheduler revalidationScheduler; - @Autowired private DashboardWebSocketController dashboardWebSocketController; @MockitoBean private RoleAuthorityService roleAuthorityService; - @MockitoBean - private UserRoleRepository userRoleRepository; - private final AtomicReference> currentRoles = new AtomicReference<>(); private WebSocketStompClient stompClient; @@ -107,9 +94,7 @@ void setUp() { stompClient.setMessageConverter(new MappingJackson2MessageConverter()); currentRoles.set(List.of("ADMIN")); given(roleAuthorityService.getRoles(USER_ID)).willAnswer(ignored -> currentRoles.get()); - given(userRoleRepository.findAllWithRoleByUserIdIn(anyList())).willAnswer(ignored -> currentRoles.get().stream() - .map(roleCode -> userRole(roleCode)) - .toList()); + given(roleAuthorityService.getRolesForAdmin(USER_ID)).willAnswer(ignored -> currentRoles.get()); } @AfterEach @@ -139,23 +124,18 @@ void rejectsDashboardSubscription_whenConnectingAfterRevocation() throws Excepti } @Test - @DisplayName("기존 ADMIN 연결의 새 구독은 재검증 전 허용되고 재검증 후 세션이 종료된다") - void closesOldSession_afterNewSubscriptionInRevalidationWindow() throws Exception { + @DisplayName("기존 ADMIN 연결의 새 구독은 주기 재검증 전에도 거부된다") + void rejectsNewSubscription_onOldSessionBeforeScheduledRevalidation() throws Exception { String email = "dashboard-old-new-subscribe@example.com"; - StompSession session = connect(email, new LinkedBlockingQueue<>()); + BlockingQueue failures = new LinkedBlockingQueue<>(); + StompSession session = connect(email, failures); try { - // 1. CONNECT 당시 저장된 ADMIN Principal을 유지한 채 DB 역할만 회수한다. + // 1. CONNECT 당시 ADMIN Principal은 유지되지만 현재 역할은 회수한다. currentRoles.set(List.of("USER")); - // 2. 주기 검사가 아직 실행되지 않은 창의 실제 SUBSCRIBE 결과를 기록한다. + // 2. 새 SUBSCRIBE는 snapshot이 아니라 현재 역할을 확인해 브로커 등록 전에 거부한다. session.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>())); - await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) - .until(() -> hasDashboardSubscription(email)); - - // 3. 재검증 후에는 물리 연결과 브로커 구독이 모두 제거되어야 한다. - revalidationScheduler.revalidate(); - awaitSessionRemoval(email); - assertThat(session.isConnected()).isFalse(); + assertThat(failures.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); assertThat(hasDashboardSubscription(email)).isFalse(); } finally { disconnect(session); @@ -163,8 +143,8 @@ void closesOldSession_afterNewSubscriptionInRevalidationWindow() throws Exceptio } @Test - @DisplayName("기존 관리자 구독은 재검증 전 push를 받지만 재검증 후에는 받지 않는다") - void stopsDashboardPush_afterExistingSubscriptionIsRevalidated() throws Exception { + @DisplayName("기존 관리자 구독에도 회수 뒤 새 push는 주기 재검증 없이 전달되지 않는다") + void stopsDashboardPush_withoutScheduledRevalidation() throws Exception { String email = "dashboard-old-subscription@example.com"; StompSession session = connect(email, new LinkedBlockingQueue<>()); BlockingQueue received = new LinkedBlockingQueue<>(); @@ -174,15 +154,14 @@ void stopsDashboardPush_afterExistingSubscriptionIsRevalidated() throws Exceptio await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) .until(() -> hasDashboardSubscription(email)); - // 2. 검사 전에는 저장된 Principal 때문에 기존 구독으로 push가 도달한다. - currentRoles.set(List.of("USER")); + // 2. 회수 전에는 실제 push가 도달함을 먼저 확인한다. dashboardWebSocketController.sendDashboardUpdate(sampleSummary(1L)); assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); - // 3. 재검증으로 연결을 닫은 뒤 보낸 새 push는 같은 세션에 도달하지 않는다. - revalidationScheduler.revalidate(); - awaitSessionRemoval(email); + // 3. 회수 뒤 새 push는 outbound 검사가 버리고 기존 연결도 닫는다. + currentRoles.set(List.of("USER")); dashboardWebSocketController.sendDashboardUpdate(sampleSummary(2L)); + awaitSessionRemoval(email); assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); } finally { disconnect(session); @@ -248,17 +227,6 @@ private DashboardSummaryResponse sampleSummary(long total) { ); } - private UserRole userRole(String roleCode) { - User user = mock(User.class); - Role role = mock(Role.class); - UserRole userRole = mock(UserRole.class); - given(user.getId()).willReturn(USER_ID); - given(role.getCode()).willReturn(roleCode); - given(userRole.getUser()).willReturn(user); - given(userRole.getRole()).willReturn(role); - return userRole; - } - private void disconnect(StompSession session) { if (session.isConnected()) { session.disconnect(); diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java new file mode 100644 index 00000000..8f2ff640 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java @@ -0,0 +1,109 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +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.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.support.MessageBuilder; + +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; + +/** + * 브로커가 기존 구독자에게 보내는 대시보드 메시지의 전달 직전 권한 판단을 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("STOMP 대시보드 outbound 인가 단위 테스트") +class StompDashboardOutboundAuthorizationInterceptorTest { + + @Mock + private StompSessionRegistry stompSessionRegistry; + + @Mock + private RoleAuthorityService roleAuthorityService; + + @Mock + private MessageChannel channel; + + @InjectMocks + private StompDashboardOutboundAuthorizationInterceptor interceptor; + + @Test + @DisplayName("현재 primary에서도 ADMIN이면 기존 구독으로 보낸 메시지를 허용한다") + void preSend_allowsDashboardMessage_whenStillAdmin() { + Message message = message("/topic/dashboard", "session-1"); + given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); + given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("ADMIN")); + + assertThat(interceptor.preSend(message, channel)).isSameAs(message); + then(stompSessionRegistry).should(never()).close("session-1"); + } + + @Test + @DisplayName("회수 뒤에는 기존 구독 메시지를 버리고 물리 세션을 닫는다") + void preSend_dropsDashboardMessage_whenAdminWasRevoked() { + Message message = message("/topic/dashboard", "session-1"); + given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); + given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("USER")); + + assertThat(interceptor.preSend(message, channel)).isNull(); + then(stompSessionRegistry).should().close("session-1"); + } + + @Test + @DisplayName("primary 역할 확인 실패도 기존 권한으로 통과시키지 않는다") + void preSend_dropsDashboardMessage_whenPrimaryIsUnavailable() { + Message message = message("/topic/dashboard", "session-1"); + given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); + given(roleAuthorityService.getRolesForAdmin(1L)).willThrow(new IllegalStateException("primary unavailable")); + + assertThat(interceptor.preSend(message, channel)).isNull(); + then(stompSessionRegistry).should().close("session-1"); + } + + @Test + @DisplayName("추적되지 않은 세션과 sessionId 없는 대시보드 메시지는 전달하지 않는다") + void preSend_dropsDashboardMessage_whenSessionIsUnknown() { + assertThat(interceptor.preSend(message("/topic/dashboard", "unknown"), channel)).isNull(); + then(stompSessionRegistry).should().close("unknown"); + + assertThat(interceptor.preSend(message("/topic/dashboard", null), channel)).isNull(); + } + + @Test + @DisplayName("대시보드 외 메시지는 추가 DB 조회 없이 통과한다") + void preSend_passesOtherDestinations() { + Message message = message("/queue/rag-answer", "session-1"); + + assertThat(interceptor.preSend(message, channel)).isSameAs(message); + then(roleAuthorityService).shouldHaveNoInteractions(); + } + + private StompSessionAuthorization authorization() { + return new StompSessionAuthorization(1L, "jti-1", Instant.now().plusSeconds(60), Set.of("ADMIN")); + } + + private Message message(String destination, String sessionId) { + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); + accessor.setDestination(destination); + if (sessionId != null) { + accessor.setSessionId(sessionId); + } + accessor.setLeaveMutable(true); + return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptorTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptorTest.java index 7b2e4854..643658b4 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptorTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDestinationAuthorizationInterceptorTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import java.security.Principal; import java.util.List; @@ -12,6 +13,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.Message; @@ -23,6 +25,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; + /** * STOMP client가 사용할 수 있는 정확한 구독 목적지와 발행 금지 정책을 단위 수준에서 검증한다. */ @@ -36,14 +40,18 @@ class StompDestinationAuthorizationInterceptorTest { @Mock private MessageChannel channel; - private final StompDestinationAuthorizationInterceptor interceptor = - new StompDestinationAuthorizationInterceptor(); + @Mock + private RoleAuthorityService roleAuthorityService; + + @InjectMocks + private StompDestinationAuthorizationInterceptor interceptor; @Test @DisplayName("정상 케이스: ADMIN은 정확한 dashboard 목적지를 구독할 수 있다") void preSend_allowsDashboardSubscription_whenAdmin() { // Given Message message = message(StompCommand.SUBSCRIBE, DASHBOARD_TOPIC, admin()); + given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("ADMIN")); // When Message result = interceptor.preSend(message, channel); @@ -76,6 +84,26 @@ void preSend_rejectsDashboardSubscription_whenNotAdmin() { .isInstanceOf(AccessDeniedException.class); } + @Test + @DisplayName("예외 케이스: 연결 당시 ADMIN이어도 현재 primary에서 회수되었으면 새 구독을 거부한다") + void preSend_rejectsDashboardSubscription_whenAdminWasRevoked() { + Message message = message(StompCommand.SUBSCRIBE, DASHBOARD_TOPIC, admin()); + given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("USER")); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + @DisplayName("예외 케이스: primary 역할을 확인할 수 없으면 이전 ADMIN 권한으로 구독하지 않는다") + void preSend_rejectsDashboardSubscription_whenPrimaryIsUnavailable() { + Message message = message(StompCommand.SUBSCRIBE, DASHBOARD_TOPIC, admin()); + given(roleAuthorityService.getRolesForAdmin(1L)).willThrow(new IllegalStateException("primary unavailable")); + + assertThatThrownBy(() -> interceptor.preSend(message, channel)) + .isInstanceOf(AccessDeniedException.class); + } + @Test @DisplayName("예외 케이스: Principal이 없으면 RAG 개인 알림 목적지를 구독할 수 없다") void preSend_rejectsRagSubscription_whenPrincipalMissing() { @@ -172,10 +200,12 @@ private Principal user() { } private Principal authentication(String email, String authority) { - return new UsernamePasswordAuthenticationToken( + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( email, null, List.of(new SimpleGrantedAuthority(authority)) ); + authentication.setDetails(1L); + return authentication; } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java index 5075fe02..93ea685b 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompSessionRegistryTest.java @@ -48,6 +48,7 @@ void authenticatedSessions_returnsOnlyAuthenticatedOpenSessions() { assertThat(registry.authenticatedSessions()) .containsExactly(new StompSessionRegistry.SessionSnapshot(SESSION_ID, authorization)); assertThat(registry.authenticatedSessionCount()).isEqualTo(1); + assertThat(registry.authorizationFor(SESSION_ID)).isEqualTo(authorization); } @Test @@ -55,6 +56,8 @@ void authenticatedSessions_returnsOnlyAuthenticatedOpenSessions() { void authenticate_returnsFalse_whenTransportMissing() { assertThat(registry.authenticate(SESSION_ID, authorization())).isFalse(); assertThat(registry.authenticatedSessions()).isEmpty(); + assertThat(registry.authorizationFor(SESSION_ID)).isNull(); + assertThat(registry.authorizationFor(null)).isNull(); } @Test @@ -73,6 +76,7 @@ void close_closesTransportAndRemovesSession() throws Exception { assertThat(closed).isTrue(); then(session).should().close(CloseStatus.POLICY_VIOLATION); assertThat(registry.authenticatedSessions()).isEmpty(); + assertThat(registry.authorizationFor(SESSION_ID)).isNull(); } @Test diff --git a/docs/test-results/gimin-371-stomp-dashboard-revocation-20261001.md b/docs/test-results/gimin-371-stomp-dashboard-revocation-20261001.md new file mode 100644 index 00000000..4d7dd380 --- /dev/null +++ b/docs/test-results/gimin-371-stomp-dashboard-revocation-20261001.md @@ -0,0 +1,87 @@ +# 관리자 역할 회수 후 WebSocket 대시보드 접근 차단 — 로컬 검증 + +관련 이슈: [#371](https://github.com/DocGrid/docgrid/issues/371) + +선행 재현: [#366 결과](gimin-%23365-stomp-dashboard-role-revocation-boundary.md) + +검증일: 2026-10-01 + +## 문제와 판정 경계 + +기존에는 STOMP CONNECT에서 읽은 `ROLE_ADMIN`을 세션 Principal에 저장했다. 이후 새 +`/topic/dashboard` SUBSCRIBE는 그 snapshot만 확인했고, 이미 브로커에 등록된 구독에는 +대시보드 메시지가 추가 권한 검사 없이 전달됐다. 따라서 관리자 역할 회수 HTTP 요청이 성공한 뒤에도 +주기 세션 재검증이 실행되기 전에는 새 구독과 기존 구독 수신이 가능했다. + +이번 수정의 판정 범위는 **역할 회수 HTTP 성공 응답 뒤 시작한 새 SUBSCRIBE와 새로 발행한 +대시보드 MESSAGE**다. 회수와 동시에 이미 outbound 검사를 통과한 in-flight 메시지가 언제 +클라이언트에 도착하는지는 보장하지 않는다. + +```text +기존: CONNECT(ADMIN snapshot) ── 회수 HTTP 200 ── SUBSCRIBE 허용 / 기존 구독 push 수신 + └─ 주기 검사(기본 5초) 뒤 세션 종료 + +변경: CONNECT(ADMIN snapshot) ── 회수 HTTP 200 ── 새 SUBSCRIBE → primary 역할 조회 → 거부 + └─ 기존 구독의 새 MESSAGE → primary 역할 조회 + → 전송 취소 + 세션 종료 +``` + +## 변경한 경계 + +| 경계 | 코드 | 결정 | +| --- | --- | --- | +| 새 관리자 구독 | `StompDestinationAuthorizationInterceptor` | CONNECT snapshot이 ADMIN이어도 `RoleAuthorityService.getRolesForAdmin()`으로 primary의 현재 역할을 다시 확인한다. 조회 실패도 거부한다. | +| 기존 구독의 새 push | `StompDashboardOutboundAuthorizationInterceptor` | `clientOutboundChannel`의 대시보드 MESSAGE마다 수신 세션을 찾고 primary 역할을 확인한다. 회수·조회 실패 시 메시지를 버리고 물리 세션을 닫는다. | +| 수신 세션 식별 | `StompSessionRegistry.authorizationFor()` | outbound MESSAGE의 `sessionId`로 해당 백엔드 인스턴스의 열린 WebSocket과 CONNECT 인증 snapshot을 찾는다. | +| 전송 파이프라인 | `WebSocketConfig.configureClientOutboundChannel()` | 새 outbound 인가기를 브로커와 실제 WebSocket 전송 사이에 등록한다. | + +`PrimaryRoleQueryService`는 동일한 read-write 트랜잭션에서 `pg_is_in_recovery()`가 +`false`인지 확인한 뒤 역할을 조회한다. Redis 역할 캐시나 standby 조회로 폴백하지 않는다. +일반 RAG 개인 알림은 이번 관리자 대시보드 검사에 포함하지 않는다. 주기 세션 재검증은 +토큰 만료·로그아웃·역할 변경 감지를 위해 그대로 유지한다. + +## 실행 위치·명령·관찰 결과 + +모든 명령은 분리된 `fix/371` worktree의 저장소 루트에서 실행했다. PostgreSQL과 Redis는 +로컬 시험 서비스다. ``은 운영 시크릿이 아닌 시험용 문자열이다. + +| 실행 위치 | 명령 또는 동작 | 관찰 결과 | 해석 | +| --- | --- | --- | --- | +| 로컬 worktree | `./backend/gradlew -p backend compileJava --offline` | `BUILD SUCCESSFUL` | 신규 outbound 인가기를 포함한 프로덕션 코드 컴파일 성공. | +| 로컬 worktree | `./backend/gradlew -p backend test --offline --tests '*StompDestinationAuthorizationInterceptorTest' --tests '*StompDashboardOutboundAuthorizationInterceptorTest'` | `BUILD SUCCESSFUL` | 새 구독·outbound 인가의 역할 회수와 primary 장애 분기를 단위 수준에서 확인. | +| 로컬 worktree | `./backend/gradlew -p backend test --offline --tests '*StompDashboardRoleRevocationIntegrationTest' --tests '*StompDashboardRealRoleRevocationIntegrationTest' --tests '*StompSessionRegistryTest'` | `BUILD SUCCESSFUL` | mock 역할 변경과 실제 HTTP·PostgreSQL·Redis 회수 흐름을 모두 통과. | +| 로컬 worktree | `JWT_SECRET= ./backend/gradlew -p backend test --offline` (최종 통합 시험 변경 전) | 209 suite, 1,257 test, 실패·오류·건너뜀 0; `BUILD SUCCESSFUL` | 당시 전체 회귀 통과. 마지막 시험 강화 이후의 전체 통과 증거로 사용하지 않는다. | +| 로컬 worktree | 위 전체 명령 (최종 통합 시험 변경 후) | 1,255건 중 1건 실패; Sync 통합 Context 생성 시 PostgreSQL `FATAL: sorry, too many clients already` | WebSocket assertion 실패가 아닌 로컬 DB 연결 한도 문제. 전체 회귀는 최종 상태에서 통과하지 못했다. | +| 로컬 worktree | Spring Test Context 캐시 상한 8을 임시 적용한 전체 명령 | 1,257건 중 68건 실패; 동일한 PostgreSQL 연결 한도 문제 | 캐시 축소만으로 해결되지 않았다. 저장소의 Gradle 설정은 변경하지 않았다. | +| 로컬 worktree | `JWT_SECRET= ./backend/gradlew -p backend test --offline --tests '*StompDashboardRealRoleRevocationIntegrationTest' --tests '*StompDashboardRoleRevocationIntegrationTest' --tests '*StompDestinationAuthorizationInterceptorTest' --tests '*StompDashboardOutboundAuthorizationInterceptorTest' --tests '*StompSessionRegistryTest'` | 5 suite, 36 test, 실패·오류·건너뜀 0; `BUILD SUCCESSFUL` | 최종 코드에서 직접 관련된 모든 시험 통과. | +| 로컬 worktree | `git diff --check` | 출력 없음 | whitespace 오류 없음. | + +실제 DB·Redis 통합 시험에서는 두 ADMIN 사용자를 연결했다. 한 사용자의 역할만 HTTP API로 +회수한 뒤, 회수된 사용자의 기존 구독에는 새 대시보드 메시지가 **0건** 도달했고 다른 정상 +ADMIN 구독자는 같은 메시지를 수신했다. 기존 연결에서 새 SUBSCRIBE도 거부됐다. Redis 캐시 +무효화를 확인한 후 의도적으로 오래된 `ADMIN` 값을 다시 넣어도 이 두 관리자 경계는 primary +역할을 사용해 거부됐다. 이 캐시 주입은 우회 방지 시험 조건이지 실제 운영에서 관찰한 +캐시 부활 사건은 아니다. 자동 세션 재검증은 1시간 뒤로 늦추고 수동 호출도 하지 않았다. + +## 비용과 미검증 범위 + +- 대시보드 MESSAGE는 **구독자별 전송마다 primary 역할 조회**가 추가된다. 즉, 전송량과 + 구독자 수가 늘면 DB·OpenProxy 부하도 함께 증가한다. 처리량·p95·p99는 이번 로컬 기능 + 시험에서 측정하지 않았으며 부하 시험 전에 운영 설정으로 간주하지 않는다. +- 이 시험은 로컬 단일 백엔드·PostgreSQL·Redis다. GCP 3노드 OpenProxy 경유, 복제 지연, + 다중 백엔드 인스턴스 사이의 결과를 실행해 확인하지 않았다. 각 인스턴스가 자기 세션에 대해 + primary를 확인하도록 설계했지만, 다중 인스턴스 보장은 별도 시험이 필요하다. +- 회수 HTTP 응답 전에 outbound 인가를 통과한 in-flight 메시지는 응답 뒤 도착할 수 있다. + 이번 결과의 0건은 **응답 뒤 새로 발행한 메시지**에 관한 것이다. +- 비동기 복제 failover가 이미 성공 응답한 역할 회수 커밋을 잃는다면 새 primary에서 + 역할이 되돌아올 수 있다. 이 문제는 WebSocket 인가 코드만으로 해결할 수 없으며 + 별도의 OpenSQL RPO 시험 대상이다. +- Redis 장애 시 CONNECT의 기존 fail-closed 계약과, 관리자 SUBSCRIBE·outbound의 + primary 조회 실패 시 fail-closed 계약은 단위 시험으로 확인했다. 실제 DB·프록시 장애를 + 주입한 시험은 하지 않았다. + +## 다음 검증 + +GCP 내부 부하 발생기에서 대시보드 구독자 수와 push 주기를 바꿔 primary 조회 비용·Hikari +대기·OpenProxy 연결 수·전달 지연을 측정한다. 이후 두 백엔드 인스턴스에 각각 WebSocket +세션을 열고 한쪽에서 역할을 회수해 양쪽의 새 구독과 새 push를 함께 확인한다.