mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge pull request #775 from iflytek/fix/cli-namespace-sync-server-route
fix(cli): add namespace sync manifest endpoint
This commit is contained in:
commit
337d3d973d
17 changed files with 457 additions and 15 deletions
|
|
@ -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<CliNamespaceSyncResponse> 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<Long, NamespaceRole> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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<CliNamespaceSyncItemResponse> items,
|
||||
String nextCursor
|
||||
) {}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Long, NamespaceRole> userNsRoles) {
|
||||
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
Page<Skill> skills = skillQueryService.listInstallableSkillsByNamespace(
|
||||
namespace, userId, roles, PageRequest.of(page, limit));
|
||||
|
||||
List<CliNamespaceSyncItemResponse> 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<Long, NamespaceRole> 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<InputStreamResource> downloadLatest(String namespace, String slug, HttpServletRequest request) {
|
||||
String userId = (String) request.getAttribute("userId");
|
||||
@SuppressWarnings("unchecked")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<String> tokenScopes) {
|
||||
if (!isApiPath(path)) {
|
||||
return ApiTokenAuthorizationDecision.allow();
|
||||
|
|
|
|||
|
|
@ -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/"));
|
||||
|
|
|
|||
|
|
@ -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/"));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<String> cliReadRoutes() {
|
||||
return Stream.of(
|
||||
"/api/cli/v1/skills/search",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<Skill> listInstallableSkillsByNamespace(
|
||||
String namespaceSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles,
|
||||
Pageable pageable) {
|
||||
|
||||
Namespace namespace = findNamespace(namespaceSlug);
|
||||
List<Skill> accessibleSkills = skillRepository
|
||||
.findByNamespaceIdAndStatus(namespace.getId(), SkillStatus.ACTIVE)
|
||||
.stream()
|
||||
.filter(skill -> visibilityChecker.canAccess(skill, currentUserId, userNsRoles))
|
||||
.toList();
|
||||
|
||||
Map<Long, SkillVersion> latestVersions = skillVersionRepository.findByIdIn(
|
||||
accessibleSkills.stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList())
|
||||
.stream()
|
||||
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
|
||||
|
||||
List<Skill> 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.
|
||||
|
|
|
|||
|
|
@ -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<Skill> 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");
|
||||
|
|
|
|||
68
web/src/api/generated/schema.d.ts
vendored
68
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue