diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliNamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliNamespaceController.java new file mode 100644 index 00000000..9fb9f140 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliNamespaceController.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse; +import com.iflytek.skillhub.ratelimit.RateLimit; +import com.iflytek.skillhub.service.cli.CliSkillAppService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * CLI namespace-scoped read endpoints. + */ +@RestController +@RequestMapping("/api/cli/v1/namespaces") +public class CliNamespaceController extends BaseApiController { + + private static final int DEFAULT_LIMIT = 100; + private static final int MAX_LIMIT = 100; + + private final CliSkillAppService cliSkillAppService; + + public CliNamespaceController(CliSkillAppService cliSkillAppService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.cliSkillAppService = cliSkillAppService; + } + + @GetMapping("/{namespace}/skills") + @RateLimit(category = "skills", authenticated = 60, anonymous = 0) + public ApiResponse listSkills( + @PathVariable String namespace, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "100") int limit, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + int page = parseCursor(cursor); + int normalizedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT); + return ok("response.success.read", cliSkillAppService.listNamespaceSkills( + namespace, + page, + normalizedLimit, + userId, + userNsRoles + )); + } + + private int parseCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return 0; + } + try { + int page = Integer.parseInt(cursor); + if (page < 0) { + throw new NumberFormatException("negative cursor"); + } + return page; + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("cursor must be a non-negative page number", ex); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncItemResponse.java new file mode 100644 index 00000000..9f743a7f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncItemResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto.cli; + +import java.time.Instant; + +/** + * Installable skill metadata used by the CLI namespace workspace synchronizer. + */ +public record CliNamespaceSyncItemResponse( + String namespace, + String slug, + String version, + Long versionId, + String fingerprint, + Instant updatedAt, + String visibility, + String downloadUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncResponse.java new file mode 100644 index 00000000..60507972 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/cli/CliNamespaceSyncResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto.cli; + +import java.util.List; + +/** + * Cursor-shaped response consumed by the CLI namespace workspace synchronizer. + */ +public record CliNamespaceSyncResponse( + List items, + String nextCursor +) {} 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 a53ee303..4ca626c2 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 @@ -59,7 +59,8 @@ public class AuthContextFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(requestPath(request))) { + if (!routeSecurityPolicyRegistry.shouldProjectRequestContext( + RouteSecurityPolicyRegistry.requestPath(request))) { filterChain.doFilter(request, response); return; } @@ -88,14 +89,6 @@ public class AuthContextFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private String requestPath(HttpServletRequest request) { - String servletPath = request.getServletPath(); - if (servletPath != null && !servletPath.isBlank()) { - return servletPath; - } - return request.getRequestURI(); - } - private boolean isInactiveUser(String userId) { if (!enforceActiveUserCheck) { return false; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java index 1fcd2e25..984b7d01 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/cli/CliSkillAppService.java @@ -1,14 +1,16 @@ package com.iflytek.skillhub.service.cli; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; -import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.dto.cli.CliDeleteResponse; import com.iflytek.skillhub.dto.cli.CliDryRunResponse; +import com.iflytek.skillhub.dto.cli.CliNamespaceSyncItemResponse; +import com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse; import com.iflytek.skillhub.dto.cli.CliPublishResponse; import com.iflytek.skillhub.dto.cli.CliResolveResponse; import com.iflytek.skillhub.service.AuditRequestContext; @@ -16,6 +18,8 @@ import com.iflytek.skillhub.service.SkillDeleteAppService; import com.iflytek.skillhub.service.SkillSearchAppService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.io.InputStreamResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -82,6 +86,42 @@ public class CliSkillAppService { ); } + public CliNamespaceSyncResponse listNamespaceSkills( + String namespace, + int page, + int limit, + String userId, + Map userNsRoles) { + Map roles = userNsRoles != null ? userNsRoles : Map.of(); + Page skills = skillQueryService.listInstallableSkillsByNamespace( + namespace, userId, roles, PageRequest.of(page, limit)); + + List items = skills.getContent().stream() + .map(skill -> toNamespaceSyncItem(skill, namespace, userId, roles)) + .toList(); + String nextCursor = skills.hasNext() ? String.valueOf(page + 1) : null; + return new CliNamespaceSyncResponse(items, nextCursor); + } + + private CliNamespaceSyncItemResponse toNamespaceSyncItem( + Skill skill, + String namespace, + String userId, + Map userNsRoles) { + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( + namespace, skill.getSlug(), null, null, null, userId, userNsRoles); + return new CliNamespaceSyncItemResponse( + namespace, + skill.getSlug(), + resolved.version(), + resolved.versionId(), + resolved.fingerprint(), + skill.getUpdatedAt(), + skill.getVisibility().name(), + resolved.downloadUrl() + ); + } + public ResponseEntity downloadLatest(String namespace, String slug, HttpServletRequest request) { String userId = (String) request.getAttribute("userId"); @SuppressWarnings("unchecked") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java index 9f6fe695..953d9ebb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/cli/CliSkillControllerTest.java @@ -132,6 +132,38 @@ class CliSkillControllerTest { verify(apiTokenService).touchLastUsed(token); } + @Test + void namespaceSyncReturnsCursorResponseForValidBearer() throws Exception { + ApiToken token = new ApiToken("sync-user", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("sync-user", "Sync User", "sync@example.com", ""); + var response = new com.iflytek.skillhub.dto.cli.CliNamespaceSyncResponse( + List.of(new com.iflytek.skillhub.dto.cli.CliNamespaceSyncItemResponse( + "team-a", "demo", "1.0.0", 42L, "sha256:fingerprint", + java.time.Instant.parse("2026-08-20T00:00:00Z"), "NAMESPACE_ONLY", + "/api/v1/skills/team-a/demo/versions/1.0.0/download" + )), + "2" + ); + + given(apiTokenService.validateToken("sync-token")).willReturn(Optional.of(token)); + given(userAccountRepository.findById("sync-user")).willReturn(Optional.of(user)); + given(userRoleBindingRepository.findByUserId("sync-user")).willReturn(List.of()); + given(cliSkillAppService.listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of())) + .willReturn(response); + + mockMvc.perform(get("/api/cli/v1/namespaces/team-a/skills") + .param("cursor", "1") + .param("limit", "25") + .header(HttpHeaders.AUTHORIZATION, "Bearer sync-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].namespace").value("team-a")) + .andExpect(jsonPath("$.data.items[0].slug").value("demo")) + .andExpect(jsonPath("$.data.items[0].version").value("1.0.0")) + .andExpect(jsonPath("$.data.nextCursor").value("2")); + + verify(cliSkillAppService).listNamespaceSkills("team-a", 1, 25, "sync-user", Map.of()); + } + @Test void resolveReturnsCliResolveResponse() throws Exception { given(cliSkillAppService.resolve("global", "demo", null, null, null)).willReturn( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java index f503c834..76b1bfd9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/cli/CliSkillAppServiceTest.java @@ -32,6 +32,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import java.math.BigDecimal; import java.time.Instant; @@ -222,6 +224,30 @@ class CliSkillAppServiceTest { assertEquals("abc123", response.fingerprint()); } + @Test + void listNamespaceSkills_mapsInstallableVersionsToCliManifest() { + Skill skill = new Skill(7L, "demo", "user-1", SkillVisibility.NAMESPACE_ONLY); + setField(skill, "id", 10L); + skill.setLatestVersionId(42L); + skill.setUpdatedBy("user-1"); + + given(skillQueryService.listInstallableSkillsByNamespace( + eq("team-a"), eq("user-1"), eq(Map.of()), eq(PageRequest.of(0, 100)))) + .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 100), 1)); + given(skillQueryService.resolveVersion("team-a", "demo", null, null, null, "user-1", Map.of())) + .willReturn(new SkillQueryService.ResolvedVersionDTO( + 10L, "team-a", "demo", "1.0.0", 42L, "sha256:fingerprint", null, + "/api/v1/skills/team-a/demo/versions/1.0.0/download")); + + var result = service.listNamespaceSkills("team-a", 0, 100, "user-1", Map.of()); + + assertEquals(1, result.items().size()); + assertEquals("demo", result.items().getFirst().slug()); + assertEquals("1.0.0", result.items().getFirst().version()); + assertEquals("sha256:fingerprint", result.items().getFirst().fingerprint()); + assertNull(result.nextCursor()); + } + @Test void deleteRemote_delegatesToDeleteAppService() { var auditContext = new AuditRequestContext("127.0.0.1", "CLI/1.0"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 812bfac3..f529b480 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -103,7 +103,7 @@ public class SecurityConfig { var csrfHandler = new CsrfTokenRequestAttributeHandler(); csrfHandler.setCsrfRequestAttributeName(null); RequestMatcher csrfIgnoreMatcher = request -> { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); String authorization = request.getHeader("Authorization"); return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request)); }; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 84caaca8..f2ecbfa6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.policy; import java.util.List; import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpMethod; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -83,6 +84,7 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"), RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/auth/whoami"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/search"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"), @@ -133,6 +135,7 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"), + ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/namespaces/*/skills"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/search"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/resolve"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"), @@ -182,6 +185,22 @@ public class RouteSecurityPolicyRegistry { return (method == null ? "ANY" : method.name()) + " " + pattern; } + /** + * Returns the application-relative request path used by security policies. + * + *

When a reverse proxy supplies {@code X-Forwarded-Prefix}, Spring exposes + * that external prefix through {@code getRequestURI()} while keeping the + * application route in {@code getServletPath()}. Security filters must match + * the latter or bearer authentication is skipped for sub-path deployments.

+ */ + public static String requestPath(HttpServletRequest request) { + String servletPath = request.getServletPath(); + if (servletPath != null && !servletPath.isBlank()) { + return servletPath; + } + return request.getRequestURI(); + } + public ApiTokenAuthorizationDecision authorizeApiToken(String method, String path, Set tokenScopes) { if (!isApiPath(path)) { return ApiTokenAuthorizationDecision.allow(); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java index 8b24aa86..841bad25 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; @@ -114,7 +115,7 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); return !(path.startsWith("/api/v1/") || path.startsWith("/api/web/") || path.startsWith("/api/cli/")); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java index 5182ce7f..55bcf385 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilter.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -47,9 +48,10 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { .map(authority -> authority.substring("SCOPE_".length())) .collect(Collectors.toSet()); + String requestPath = RouteSecurityPolicyRegistry.requestPath(request); ApiTokenScopeService.AuthorizationDecision decision = apiTokenScopeService.authorize( request.getMethod(), - request.getRequestURI(), + requestPath, tokenScopes ); @@ -60,13 +62,13 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter { ApiTokenAccessDeniedException exception = decision.requiredScope() != null ? ApiTokenAccessDeniedException.missingScope(decision.requiredScope()) - : ApiTokenAccessDeniedException.unsupportedEndpoint(request.getRequestURI()); + : ApiTokenAccessDeniedException.unsupportedEndpoint(requestPath); accessDeniedHandler.handle(request, response, exception); } @Override protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); + String path = RouteSecurityPolicyRegistry.requestPath(request); return path == null || (!path.startsWith("/api/v1/") && !path.startsWith("/api/web/") && !path.startsWith("/api/cli/")); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 7e071a01..36e6b462 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; class RouteSecurityPolicyRegistryTest { @@ -98,6 +99,7 @@ class RouteSecurityPolicyRegistryTest { @Test void apiTokenPolicySupportsNativeCliRoutes() { assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/auth/whoami", Set.of()).allowed()); + assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/namespaces/team-a/skills", Set.of()).allowed()); assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/search", Set.of()).allowed()); assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/global/demo/resolve", Set.of()).allowed()); assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of()).allowed()); @@ -107,6 +109,16 @@ class RouteSecurityPolicyRegistryTest { assertTrue(registry.authorizeApiToken("DELETE", "/api/cli/v1/skills/global/demo", Set.of("skill:delete")).allowed()); } + @Test + void routeAuthorizationRequiresAuthenticationForNativeCliNamespaceSync() { + boolean matched = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/cli/v1/namespaces/*/skills".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED); + + assertTrue(matched); + } + @Test void routeAuthorizationProtectsNativeCliRemoteDeleteByAuthenticationNotSuperAdminRole() { boolean matched = registry.authorizationPolicies().stream() @@ -136,6 +148,18 @@ class RouteSecurityPolicyRegistryTest { assertFalse(registry.shouldProjectRequestContext("/assets/index.css")); } + @Test + void requestPathUsesApplicationRouteBehindForwardedPrefix() { + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/skillhub/api/cli/v1/auth/whoami" + ); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/auth/whoami"); + + assertEquals("/api/cli/v1/auth/whoami", RouteSecurityPolicyRegistry.requestPath(request)); + } + @Test void authorizeApiToken_allowsPublicLabelCatalogue() { assertTrue(registry.authorizeApiToken("GET", "/api/v1/labels", Set.of()).allowed()); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java index fa9fb2cf..ee4e4bcb 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java @@ -227,6 +227,26 @@ class ApiTokenAuthenticationFilterTest { verify(apiTokenService).touchLastUsed(token); } + @Test + void shouldAuthenticateBearerTokensBehindForwardedPrefix() throws Exception { + ApiToken token = new ApiToken("user-4", "cli", "sk_test", "hash", "[\"skill:read\"]"); + UserAccount user = new UserAccount("user-4", "Dana", "dana@example.com", ""); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-4")).thenReturn(Optional.of(user)); + when(roleBindingRepository.findByUserId("user-4")).thenReturn(List.of()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/skillhub/api/cli/v1/auth/whoami"); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/auth/whoami"); + request.addHeader("Authorization", "Bearer raw-token"); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + verify(apiTokenService).touchLastUsed(token); + } + private static List cliReadRoutes() { return Stream.of( "/api/cli/v1/skills/search", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java index 085016f4..e225ef7b 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenScopeFilterTest.java @@ -180,4 +180,42 @@ class ApiTokenScopeFilterTest { assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish")); verify(chain, never()).doFilter(request, response); } + + @Test + void shouldAuthorizeApplicationPathBehindForwardedPrefix() throws Exception { + AccessDeniedHandler handler = mock(AccessDeniedHandler.class); + ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler); + + PlatformPrincipal principal = new PlatformPrincipal( + "user-5", + "Erin", + "erin@example.com", + "", + "api_token", + Set.of("USER") + ); + var authentication = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of( + new SimpleGrantedAuthority("ROLE_USER"), + new SimpleGrantedAuthority("SCOPE_skill:read") + ) + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/skillhub/api/cli/v1/namespaces/global/skills" + ); + request.setContextPath("/skillhub"); + request.setServletPath("/api/cli/v1/namespaces/global/skills"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(handler, never()).handle(eq(request), eq(response), any()); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 66c5e319..c7a86cd7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -37,6 +37,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TreeSet; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -286,6 +287,47 @@ public class SkillQueryService { return new PageImpl<>(pageContent, pageable, accessibleSkills.size()); } + /** + * Lists only active, visible skills whose latest version can be installed. + * + *

This is intentionally separate from {@link #listSkillsByNamespace}: + * the portal discovery method also exposes skills without a published + * version, while the CLI sync manifest must contain concrete downloadable + * versions. Filtering happens before pagination so cursors remain stable. + */ + public Page listInstallableSkillsByNamespace( + String namespaceSlug, + String currentUserId, + Map userNsRoles, + Pageable pageable) { + + Namespace namespace = findNamespace(namespaceSlug); + List accessibleSkills = skillRepository + .findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE) + .stream() + .filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) + .toList(); + + Map latestVersions = skillVersionRepository.findByIdIn( + accessibleSkills.stream() + .map(Skill::getLatestVersionId) + .filter(Objects::nonNull) + .distinct() + .toList()) + .stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List installableSkills = accessibleSkills.stream() + .filter(skill -> SkillInstallability.isInstallableVersion(latestVersions.get(skill.getLatestVersionId()))) + .sorted(Comparator.comparing(Skill::getSlug) + .thenComparing(Skill::getId, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + int start = Math.min((int) pageable.getOffset(), installableSkills.size()); + int end = Math.min(start + pageable.getPageSize(), installableSkills.size()); + return new PageImpl<>(installableSkills.subList(start, end), pageable, installableSkills.size()); + } + /** * Returns metadata for a visible version, including the stored manifest and * parsed metadata payload. diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index dc5782c4..7fb08e3c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -239,6 +239,46 @@ class SkillQueryServiceTest { verify(skillRepository).findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE); } + @Test + void installableNamespaceDiscoveryFiltersBeforePaginationAndSortsBySlug() throws Exception { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setId(namespace, 1L); + Skill ready = new Skill(1L, "ready", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(ready, 10L); + ready.setLatestVersionId(100L); + Skill draft = new Skill(1L, "draft", "owner-1", SkillVisibility.NAMESPACE_ONLY); + setId(draft, 11L); + draft.setLatestVersionId(101L); + Skill publicReady = new Skill(1L, "public-ready", "owner-2", SkillVisibility.PUBLIC); + setId(publicReady, 12L); + publicReady.setLatestVersionId(102L); + + SkillVersion readyVersion = new SkillVersion(10L, "1.0.0", "owner-1"); + setId(readyVersion, 100L); + readyVersion.setStatus(SkillVersionStatus.PUBLISHED); + readyVersion.setDownloadReady(true); + SkillVersion draftVersion = new SkillVersion(11L, "1.0.0", "owner-1"); + setId(draftVersion, 101L); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + SkillVersion publicReadyVersion = new SkillVersion(12L, "2.0.0", "owner-2"); + setId(publicReadyVersion, 102L); + publicReadyVersion.setStatus(SkillVersionStatus.PUBLISHED); + publicReadyVersion.setDownloadReady(true); + + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)) + .thenReturn(List.of(ready, draft, publicReady)); + when(skillVersionRepository.findByIdIn(List.of(100L, 101L, 102L))) + .thenReturn(List.of(readyVersion, draftVersion, publicReadyVersion)); + + Page result = service.listInstallableSkillsByNamespace( + "team-a", "viewer", Map.of(1L, NamespaceRole.MEMBER), PageRequest.of(0, 1)); + + assertEquals(2, result.getTotalElements()); + assertEquals("public-ready", result.getContent().getFirst().getSlug()); + assertTrue(result.hasNext()); + } + @Test void versionContentRejectsPublicArchivedSkillForNonManager() throws Exception { Namespace namespace = new Namespace("global", "Global", "owner-1"); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 423ee239..69d55df9 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -3348,6 +3348,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/cli/v1/namespaces/{namespace}/skills": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSkills_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cli/v1/auth/whoami": { parameters: { query?: never; @@ -4347,6 +4363,8 @@ export interface components { namespace?: string; /** Format: date-time */ updatedAt?: string; + ownerId?: string; + ownerDisplayName?: string; canSubmitPromotion?: boolean; headlineVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; publishedVersion?: components["schemas"]["SkillLifecycleVersionResponse"]; @@ -5330,6 +5348,31 @@ export interface components { /** Format: int32 */ limit?: number; }; + ApiResponseCliNamespaceSyncResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["CliNamespaceSyncResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + CliNamespaceSyncItemResponse: { + namespace?: string; + slug?: string; + version?: string; + /** Format: int64 */ + versionId?: number; + fingerprint?: string; + /** Format: date-time */ + updatedAt?: string; + visibility?: string; + downloadUrl?: string; + }; + CliNamespaceSyncResponse: { + items?: components["schemas"]["CliNamespaceSyncItemResponse"][]; + nextCursor?: string; + }; ApiResponseCliWhoAmIResponse: { /** Format: int32 */ code?: number; @@ -11357,6 +11400,31 @@ export interface operations { }; }; }; + listSkills_1: { + parameters: { + query?: { + cursor?: string; + limit?: number; + }; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliNamespaceSyncResponse"]; + }; + }; + }; + }; whoami_1: { parameters: { query?: never;