fix(auth): close API token scope filter gap on /api/cli/ routes

ApiTokenAuthenticationFilter authenticates /api/cli/** Bearer tokens
but ApiTokenScopeFilter.shouldNotFilter() previously skipped them.
The result: API token requests on CLI routes were authenticated and
authorization-policy-checked, but scope enforcement never ran. Tokens
without skill:publish or skill:delete could call /publish, /publish/validate,
and DELETE despite the policy table requiring those scopes.

Add /api/cli/ to the scope filter's covered prefixes and a filter-level
test that confirms a token missing skill:publish is rejected on the new
validate endpoint. Update the existing CLI controller tests to grant
the appropriate SCOPE_* authorities to their api_token principals so
they continue to pass under enforced scopes.
This commit is contained in:
dongmucat 2026-05-18 15:51:19 +08:00
parent 943294b558
commit d7d0790b28
4 changed files with 44 additions and 3 deletions

View file

@ -37,7 +37,9 @@ class CliDryRunValidateTest {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
return new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
principal, null, List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:publish")));
}
@Test

View file

@ -103,7 +103,9 @@ class CliSkillControllerTest {
PlatformPrincipal principal = new PlatformPrincipal(
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
principal, null, List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("SCOPE_skill:delete")));
given(cliSkillAppService.deleteRemote(
org.mockito.ArgumentMatchers.eq("global"),

View file

@ -70,7 +70,8 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path == null || (!path.startsWith("/api/v1/")
&& !path.startsWith("/api/web/"));
&& !path.startsWith("/api/web/")
&& !path.startsWith("/api/cli/"));
}
private boolean isApiTokenAuthentication(Authentication authentication) {

View file

@ -134,4 +134,40 @@ class ApiTokenScopeFilterTest {
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
@Test
void shouldDenyApiCliRequestsWithoutRequiredScope() throws Exception {
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
};
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
PlatformPrincipal principal = new PlatformPrincipal(
"user-4",
"Dave",
"dave@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("POST", "/api/cli/v1/skills/global/publish/validate");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
verify(chain, never()).doFilter(request, response);
}
}