From e01c987a82a16c217a217bb30d71cbb75fc8b6be Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 21 Apr 2026 09:45:39 +0800 Subject: [PATCH] fix(search): restore clawhub namespace-only access --- .../compat/ClawHubRegistrySecurityConfig.java | 9 ++- .../skillhub/filter/AuthContextFilter.java | 3 + .../compat/ClawHubCompatControllerTest.java | 67 +++++++++++++++++++ .../PostgresFullTextQueryService.java | 6 -- .../PostgresFullTextQueryServiceTest.java | 28 ++++++++ 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java index 7e7da3bc..e0df66d5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.compat; +import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; @@ -37,7 +39,9 @@ public class ClawHubRegistrySecurityConfig { @Bean @Order(1) - public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain clawHubRegistryFilterChain( + HttpSecurity http, + ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) throws Exception { http .securityMatcher( "/api/v1/search", @@ -46,7 +50,8 @@ public class ClawHubRegistrySecurityConfig { ) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .requestCache(cache -> cache.disable()) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index d4df24c3..1c16fe89 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -16,7 +16,9 @@ import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -28,6 +30,7 @@ import org.springframework.web.filter.OncePerRequestFilter; * Projects the authenticated principal into request attributes consumed by the controller layer. */ @Component +@Order(SecurityProperties.DEFAULT_FILTER_ORDER + 1) public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index a78c7d73..607e3d65 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -2,15 +2,22 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; @@ -39,6 +46,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -67,6 +75,15 @@ class ClawHubCompatControllerTest { @MockBean private SkillQueryService skillQueryService; + @MockBean + private ApiTokenService apiTokenService; + + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @MockBean private CompatSkillLookupService compatSkillLookupService; @@ -111,6 +128,56 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.results[0].version").value("1.2.0")); } + @Test + void search_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception { + ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-7", "Alice", "alice@example.com", null); + var nsRoles = java.util.Map.of(9L, NamespaceRole.MEMBER); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-7")).thenReturn(Optional.of(user)); + when(userRoleBindingRepository.findByUserId("user-7")).thenReturn(List.of()); + when(namespaceMemberRepository.findByUserId("user-7")) + .thenReturn(List.of(new NamespaceMember(9L, "user-7", NamespaceRole.MEMBER))); + when(skillSearchAppService.search("token-search", null, "relevance", 0, 20, "user-7", nsRoles)) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/v1/search") + .param("q", "token-search") + .header("Authorization", "Bearer raw-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results").isArray()); + + verify(skillSearchAppService).search("token-search", null, "relevance", 0, 20, "user-7", nsRoles); + verify(apiTokenService).touchLastUsed(same(token)); + } + + @Test + void downloadQuery_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception { + ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-7", "Alice", "alice@example.com", null); + var nsRoles = java.util.Map.of(9L, NamespaceRole.MEMBER); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-7")).thenReturn(Optional.of(user)); + when(userRoleBindingRepository.findByUserId("user-7")).thenReturn(List.of()); + when(namespaceMemberRepository.findByUserId("user-7")) + .thenReturn(List.of(new NamespaceMember(9L, "user-7", NamespaceRole.MEMBER))); + when(compatSkillLookupService.findByLegacySlug("private-skill")) + .thenReturn(legacyCompatContext("team-ai", "private-skill")); + when(compatSkillLookupService.canAccess(any(), eq("user-7"), eq(nsRoles))).thenReturn(true); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "private-skill") + .param("version", "latest") + .header("Authorization", "Bearer raw-token")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "/api/v1/skills/team-ai/private-skill/download")); + + verify(compatSkillLookupService).canAccess(any(), eq("user-7"), eq(nsRoles)); + verify(apiTokenService).touchLastUsed(same(token)); + } + @Test void resolve_returns_correct_downloadUrl() throws Exception { when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of())) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index ec0cfa37..2e1ffcb1 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -102,10 +102,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); - Set adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty() - ? Set.of(-1L) - : query.visibilityScope().adminNamespaceIds(); - StringBuilder sql = new StringBuilder(); sql.append("SELECT d.skill_id "); sql.append("FROM skill_search_document d "); @@ -190,7 +186,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds); - nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds); } if (query.namespaceId() != null) { @@ -235,7 +230,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { countQuery.setParameter("memberNamespaceIds", memberNamespaceIds); - countQuery.setParameter("adminNamespaceIds", adminNamespaceIds); } if (query.namespaceId() != null) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 0025f2ec..f89d05e9 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -392,6 +392,34 @@ class PostgresFullTextQueryServiceTest { assertThat(sqlCaptor.getAllValues().getFirst()).contains("OR d.namespace_id IN :memberNamespaceIds"); } + @Test + void authenticatedQueriesShouldNotBindUnusedAdminNamespaceIdsParameter() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "issue331", + null, + new SearchVisibilityScope("user-1", Set.of(7L), Set.of(9L)), + "relevance", + 0, + 20 + )); + + verify(nativeQuery, never()).setParameter("adminNamespaceIds", Set.of(9L)); + verify(countQuery, never()).setParameter("adminNamespaceIds", Set.of(9L)); + } + @Test void platformWideAccessShouldNotBypassVisibilityInPortalSearch() { EntityManager entityManager = mock(EntityManager.class);