Merge pull request #334 from iflytek/fix/namespace-search-visibility

fix(search): restore clawhub namespace-only access
This commit is contained in:
dongmucat 2026-04-21 16:47:19 +08:00 committed by GitHub
commit 3c047fce8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 105 additions and 8 deletions

View file

@ -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();
}

View file

@ -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;

View file

@ -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()))

View file

@ -102,10 +102,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
Set<Long> memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().memberNamespaceIds();
Set<Long> 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) {

View file

@ -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);