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를 함께 확인한다.