This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 69abafac fix(auth): enforce admin access for mutating APIs (#1007)
69abafac is described below

commit 69abafacd5d2b96f41c3c03f85adf21db426ba2e
Author: yyqdbngt <[email protected]>
AuthorDate: Wed Aug 5 17:44:43 2026 +0800

    fix(auth): enforce admin access for mutating APIs (#1007)
    
    Non-admin sessions were authenticated but their role was ignored, so
    direct API requests could bypass the read-only frontend controls.
    
    Deny protected mutations by default while preserving explicit read-only
    POST endpoints for reader sessions.
---
 docs/api-spec.md                                   |  5 ++
 .../rocketmq/studio/auth/AuthInterceptor.java      | 43 ++++++++--
 .../apache/rocketmq/studio/auth/AuthService.java   | 13 +++
 .../studio/auth/AuthCorsIntegrationTest.java       | 20 +++++
 .../rocketmq/studio/auth/AuthInterceptorTest.java  | 96 +++++++++++++++++++++-
 .../rocketmq/studio/auth/AuthServiceTest.java      |  2 +
 6 files changed, 170 insertions(+), 9 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index dafe277f..f83f2894 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -170,6 +170,11 @@ POST /api/auth/login
 | `user.username` | `string` | 用户名 |
 | `user.admin` | `boolean` | 是否管理员 |
 
+启用 `studio.auth.login-required` 后,非管理员会话为只读角色。GET、HEAD
+和只读查询接口可访问;创建、更新、删除、重启、发送消息等写操作要求
+`user.admin=true`,否则返回 HTTP 403。登录、登出、CORS 预检及明确的只读
+POST 查询不受管理员限制。
+
 ### 1.2 登出
 
 ```
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index df42a591..be8aeff9 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -21,14 +21,25 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.web.cors.CorsUtils;
 import org.springframework.web.servlet.HandlerInterceptor;
 
+import java.util.Set;
+
 @RequiredArgsConstructor
 public class AuthInterceptor implements HandlerInterceptor {
 
+    private static final Set<String> READER_POST_PATHS = Set.of(
+            "/api/auth/logout",
+            "/api/ai/chat",
+            "/api/clusters/test-connection",
+            "/api/llm/config/test",
+            "/api/metrics/query",
+            "/api/settings/datasources/test");
+
     private final AuthProperties authProperties;
     private final AuthService authService;
 
@@ -41,16 +52,34 @@ public class AuthInterceptor implements HandlerInterceptor {
             return true;
         }
         String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
-        if (authService.isAuthenticated(authorization)) {
-            authService.getAuthenticatedUser(authorization)
-                    .ifPresent(user -> 
AuthenticatedUserContext.setUsername(user.getUsername()));
-            return true;
+        if (!authService.isAuthenticated(authorization)) {
+            writeError(response, HttpStatus.UNAUTHORIZED, "Unauthorized");
+            return false;
+        }
+        authService.getAuthenticatedUser(authorization)
+                .ifPresent(user -> 
AuthenticatedUserContext.setUsername(user.getUsername()));
+        if (requiresAdmin(request, requestPath(request)) && 
!authService.isAdmin(authorization)) {
+            writeError(response, HttpStatus.FORBIDDEN, "Admin permission 
required");
+            return false;
         }
+        return true;
+    }
+
+    private boolean requiresAdmin(HttpServletRequest request, String path) {
+        String method = request.getMethod();
+        if (HttpMethod.GET.matches(method) || HttpMethod.HEAD.matches(method)
+                || HttpMethod.OPTIONS.matches(method)) {
+            return false;
+        }
+        return !HttpMethod.POST.matches(method) || 
!READER_POST_PATHS.contains(normalizePath(path));
+    }
 
-        response.setStatus(HttpStatus.UNAUTHORIZED.value());
+    private void writeError(HttpServletResponse response, HttpStatus status, 
String message)
+            throws Exception {
+        response.setStatus(status.value());
         response.setContentType(MediaType.APPLICATION_JSON_VALUE);
-        
response.getWriter().write("{\"code\":401,\"message\":\"Unauthorized\",\"data\":null}");
-        return false;
+        response.getWriter().write("{\"code\":" + status.value()
+                + ",\"message\":\"" + message + "\",\"data\":null}");
     }
 
     @Override
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 5e60d8bc..b81ce2ba 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -108,6 +108,19 @@ public class AuthService {
         return Optional.of(session.user());
     }
 
+    public boolean isAdmin(String authorization) {
+        Optional<String> token = tokenFromAuthorization(authorization);
+        if (token.isEmpty()) {
+            return false;
+        }
+        AuthSession session = activeTokens.get(token.get());
+        if (session == null || session.expiresAtMillis() <= clock.millis()) {
+            activeTokens.remove(token.get());
+            return false;
+        }
+        return session.user().isAdmin();
+    }
+
     public void logout(String authorization) {
         tokenFromAuthorization(authorization).ifPresent(activeTokens::remove);
         log.info("User logged out");
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
index 0818af6d..0d5701ae 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthCorsIntegrationTest.java
@@ -31,11 +31,14 @@ import org.springframework.http.HttpHeaders;
 import org.springframework.test.web.servlet.MockMvc;
 
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
 @WebMvcTest(value = InstanceController.class, properties = 
"studio.auth.login-required=true")
@@ -81,4 +84,21 @@ class AuthCorsIntegrationTest {
 
         verify(authService).isAuthenticated(null);
     }
+    @Test
+    void shouldRejectNonAdminMutationBeforeControllerExecution() throws 
Exception {
+        String authorization = "Bearer reader-token";
+        when(authService.isAuthenticated(authorization)).thenReturn(true);
+        when(authService.isAdmin(authorization)).thenReturn(false);
+
+        mockMvc.perform(post("/api/instances/create")
+                        .header(HttpHeaders.AUTHORIZATION, authorization)
+                        .contentType("application/json")
+                        .content("{}"))
+                .andExpect(status().isForbidden())
+                .andExpect(jsonPath("$.code").value(403))
+                .andExpect(jsonPath("$.message").value("Admin permission 
required"));
+
+        verifyNoInteractions(instanceService);
+    }
+
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index e1d5951f..d1f77071 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -146,10 +146,102 @@ class AuthInterceptorTest {
     }
 
     private AuthService authService(AuthProperties properties) {
+        return new AuthService(properties, settingsRepositoryWithTimeout(30));
+    }
+
+    private SettingsRepository settingsRepositoryWithTimeout(int 
sessionTimeout) {
         SettingsRepository settingsRepository = mock(SettingsRepository.class);
         
when(settingsRepository.loadGeneralSettings()).thenReturn(GeneralSettingsVO.builder()
-                .sessionTimeout(30)
+                .sessionTimeout(sessionTimeout)
                 .build());
-        return new AuthService(properties, settingsRepository);
+        return settingsRepository;
     }
+
+    @Test
+    void shouldAllowReadOnlyGetForNonAdminUser() throws Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "GET", "/api/clusters", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
+    @Test
+    void shouldRejectMutatingPostForNonAdminUser() throws Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/ops/updateUseTLS", session.token());
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean allowed = session.interceptor().preHandle(request, response, 
new Object());
+
+        assertThat(allowed).isFalse();
+        assertThat(response.getStatus()).isEqualTo(403);
+        assertThat(response.getContentAsString()).contains("Admin permission 
required");
+    }
+
+    @Test
+    void shouldAllowMutatingPostForAdminUser() throws Exception {
+        TestSession session = login(true);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/ops/updateUseTLS", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
+    @Test
+    void shouldAllowReadOnlyPostForNonAdminUser() throws Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/metrics/query/", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
+    @Test
+    void shouldAllowLogoutForNonAdminUser() throws Exception {
+        TestSession session = login(false);
+        MockHttpServletRequest request = authenticatedRequest(
+                "POST", "/api/auth/logout", session.token());
+
+        boolean allowed = session.interceptor().preHandle(
+                request, new MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
+
+    private TestSession login(boolean admin) {
+        AuthProperties properties = new AuthProperties();
+        properties.setLoginRequired(true);
+        AuthProperties.User user = new AuthProperties.User();
+        user.setUsername("test-user");
+        user.setPassword("secret");
+        user.setAdmin(admin);
+        properties.setUsers(List.of(user));
+        AuthService authService = new AuthService(properties, 
settingsRepositoryWithTimeout(30));
+        LoginDTO login = new LoginDTO();
+        login.setUsername("test-user");
+        login.setPassword("secret");
+        String token = authService.login(login).getToken();
+        return new TestSession(new AuthInterceptor(properties, authService), 
token);
+    }
+
+    private MockHttpServletRequest authenticatedRequest(String method, String 
path, String token) {
+        MockHttpServletRequest request = new MockHttpServletRequest(method, 
path);
+        request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
+        return request;
+    }
+
+    private record TestSession(AuthInterceptor interceptor, String token) {
+    }
+
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
index 5ef26650..35eb94c1 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
@@ -77,6 +77,7 @@ class AuthServiceTest {
         assertThat(authService.getAuthenticatedUser("Bearer " + 
response.getToken()))
                 .hasValueSatisfying(userInfo -> 
assertThat(userInfo.getUsername()).isEqualTo("testuser"));
         assertThat(authService.getAuthenticatedUser("Bearer 
unknown-token")).isEmpty();
+        assertThat(authService.isAdmin("Bearer " + 
response.getToken())).isFalse();
     }
 
     @Test
@@ -94,6 +95,7 @@ class AuthServiceTest {
 
         assertThat(response.getUser().getUsername()).isEqualTo("admin");
         assertThat(response.getUser().isAdmin()).isTrue();
+        assertThat(authService.isAdmin("Bearer " + 
response.getToken())).isTrue();
     }
 
     @Test

Reply via email to