mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
Merge pull request #334 from iflytek/fix/namespace-search-visibility
fix(search): restore clawhub namespace-only access
This commit is contained in:
commit
3c047fce8a
5 changed files with 105 additions and 8 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue