This is an automated email from the ASF dual-hosted git repository. tomsun28 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 62df445dbae5d5af529fa57511dfecb0cb9d3f70 Merge: 288dcab3ad aafa8cc2e7 Author: tomsun28 <[email protected]> AuthorDate: Fri Oct 9 18:41:54 2026 +0800 Merge remote-tracking branch 'origin/master' into 2.0.0 # Conflicts: # hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java # hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java .github/workflows/backend-build-test.yml | 15 +- .github/workflows/docker-compose-config-test.yml | 6 +- e2e/README.md | 3 + .../org/apache/hertzbeat/common/util/XmlUtil.java | 2 +- .../apache/hertzbeat/common/util/XmlUtilTest.java | 83 +++++++ .../manager/config/ApiTokenValidationFilter.java | 90 +++++--- .../config/ApiTokenValidationFilterTest.java | 250 +++++++++++++-------- .../src/main/resources/application.yml | 1 - home/docs/start/docker-compose-deploy.md | 2 + home/docs/start/docker-deploy.md | 17 ++ home/docs/start/quickstart.md | 2 +- .../current/help/tdengine_promql.md | 2 +- .../current/start/docker-compose-deploy.md | 2 + .../current/start/docker-deploy.md | 17 ++ .../current/start/quickstart.md | 2 +- .../current/start/usecase/tdengine-practice.md | 2 +- script/application.yml | 1 - script/ci/auth_contract_test.py | 198 ++++++++++++++++ script/ci/check-quickstart-compose.sh | 23 +- script/docker-compose/README.md | 10 + .../hertzbeat-mysql-iotdb/conf/application.yml | 1 - .../hertzbeat-mysql-tdengine/conf/application.yml | 1 - .../conf/application.yml | 1 - .../conf/application.yml | 1 - .../conf/application.yml | 1 - 25 files changed, 592 insertions(+), 141 deletions(-) diff --cc hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java index 3651562ab6,0da8c65197..2e39090b8e --- a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java +++ b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java @@@ -25,19 -27,19 +27,23 @@@ import jakarta.servlet.http.HttpServlet import java.io.IOException; import java.io.PrintWriter; + import java.util.ArrayList; import java.util.Collections; + import java.util.LinkedHashSet; import java.util.List; import java.util.Map; + import java.util.Set; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.hertzbeat.collector.dispatch.DispatchConstants; import org.apache.hertzbeat.common.constants.NetworkConstants; +import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext; +import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes; +import org.apache.hertzbeat.common.observability.gateway.ObservabilityAccessTokenGateway; import org.apache.hertzbeat.common.util.JsonUtil; import org.apache.hertzbeat.manager.service.AccountService; + import org.apache.hertzbeat.manager.service.impl.AccountServiceImpl; import org.jspecify.annotations.NonNull; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@@ -66,7 -68,8 +72,9 @@@ public class ApiTokenValidationFilter i private static final String ROLES_CLAIM = "roles"; private static final String TOKEN_VALIDATION_UNAVAILABLE = "Token validation unavailable"; + private static final String WORKSPACE_ACCESS_DENIED = "Workspace access denied"; + private static final String TOKEN_NOT_RESOLVED = "Token could not be resolved"; + private static final String TOKEN_PARAM = "token"; private final AccountService accountService; @@@ -77,53 -80,60 +85,79 @@@ @Override public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws IOException { + AuthTokenRequestContext.clear(); SubjectSum subject = SurenessContextHolder.getBindSubject(); + bindAuthenticatedWorkspace(subject); + String authenticatedWorkspaceId = AuthTokenRequestContext.currentAuthenticatedWorkspaceId(); if (subject == null || !isManagedToken(subject)) { - return true; + return bindAuthorizedWorkspace(request, authenticatedWorkspaceId) + || writeWorkspaceError(response); } - String authorization = request.getHeader(NetworkConstants.AUTHORIZATION); - - if (authorization != null && authorization.startsWith(DispatchConstants.BEARER)) { - String token = authorization.substring(DispatchConstants.BEARER.length()).trim(); - if (!token.isEmpty()) { - try { - String rejectReason = checkManagedToken( - subject, - token, - resolveRequiredScope(request), - authenticatedWorkspaceId); - if (rejectReason != null) { - return writeError(response, HttpStatus.UNAUTHORIZED, rejectReason); - } - if (!bindAuthorizedWorkspace(request, authenticatedWorkspaceId)) { - return writeWorkspaceError(response); - } - rejectReason = bindManagedCollectorBoundary(request, subject); - if (rejectReason != null) { - return writeError(response, HttpStatus.UNAUTHORIZED, rejectReason); - } - touchTokenLastUsedTime(token); - } catch (RuntimeException e) { - log.warn("Managed token validation failed ({})", e.getClass().getSimpleName()); - return writeError(response, HttpStatus.SERVICE_UNAVAILABLE, TOKEN_VALIDATION_UNAVAILABLE); + // Sureness reads a jwt from the Authorization header and from the token query parameter, + // so every managed token the request carries is validated, and a managed subject whose + // token cannot be found is rejected rather than let through unchecked + List<String> managedTokens = resolveManagedTokens(request); + if (managedTokens.isEmpty()) { + return writeError(response, HttpStatus.UNAUTHORIZED, TOKEN_NOT_RESOLVED); + } + try { + for (String token : managedTokens) { - String rejectReason = checkManagedToken(subject, token); ++ String rejectReason = checkManagedToken( ++ subject, token, resolveRequiredScope(request), authenticatedWorkspaceId); + if (rejectReason != null) { + return writeError(response, HttpStatus.UNAUTHORIZED, rejectReason); } } + } catch (RuntimeException e) { + log.warn("Managed token validation failed", e); + return writeError(response, HttpStatus.SERVICE_UNAVAILABLE, TOKEN_VALIDATION_UNAVAILABLE); } - - return bindAuthorizedWorkspace(request, authenticatedWorkspaceId) - || writeWorkspaceError(response); ++ if (!bindAuthorizedWorkspace(request, authenticatedWorkspaceId)) { ++ return writeWorkspaceError(response); ++ } ++ String rejectReason = bindManagedCollectorBoundary(request, subject); ++ if (rejectReason != null) { ++ return writeError(response, HttpStatus.UNAUTHORIZED, rejectReason); ++ } ++ managedTokens.forEach(this::touchTokenLastUsedTime); + return true; } + @Override + public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, + @NonNull Object handler, Exception ex) { + AuthTokenRequestContext.clear(); + } + + private List<String> resolveManagedTokens(HttpServletRequest request) { + Set<String> candidates = new LinkedHashSet<>(2); + String authorization = request.getHeader(NetworkConstants.AUTHORIZATION); + if (authorization != null && authorization.startsWith(DispatchConstants.BEARER)) { + // mirror Sureness JwtSubjectJakartaServletCreator exactly, so the validated token is the one it authenticated + candidates.add(authorization.replace(DispatchConstants.BEARER, "").trim()); + } + String queryToken = request.getParameter(TOKEN_PARAM); + if (queryToken != null) { + candidates.add(queryToken.trim()); + } + List<String> managedTokens = new ArrayList<>(candidates.size()); + for (String candidate : candidates) { + if (!candidate.isEmpty() && isManagedJwt(candidate)) { + managedTokens.add(candidate); + } + } + return managedTokens; + } + + private boolean isManagedJwt(String token) { + try { + Claims claims = JsonWebTokenUtil.parseJwt(token); + return Boolean.TRUE.equals(claims.get(AccountServiceImpl.CLAIM_MANAGED, Boolean.class)); + } catch (Exception e) { + return false; + } + } + /** * Check if a managed token should be rejected. * <p> diff --cc hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java index 88e53c78c5,042d966725..c4773c8998 --- a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java @@@ -17,9 -17,11 +17,12 @@@ package org.apache.hertzbeat.manager.config; + import static org.junit.jupiter.api.Assertions.assertFalse; + import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.lenient; + import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@@ -32,19 -34,17 +35,23 @@@ import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.io.StringWriter; + import java.util.HashMap; import java.util.List; + import java.util.Map; import org.apache.hertzbeat.common.constants.NetworkConstants; +import org.apache.hertzbeat.common.observability.gateway.AuthTokenRequestContext; +import org.apache.hertzbeat.common.observability.gateway.AuthTokenScopes; +import org.apache.hertzbeat.common.observability.gateway.ObservabilityAccessTokenGateway; import org.apache.hertzbeat.manager.service.AccountService; +import org.junit.jupiter.api.AfterEach; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; + import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; /** @@@ -112,362 -126,130 +145,392 @@@ class ApiTokenValidationFilterTest void testLegacyTokenPassesThrough() throws Exception { SubjectSum subject = mockSubject(); - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); - - org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); - verify(accountService, never()).checkTokenStatus(any()); - } + assertTrue(preHandle(subject)); + verify(accountService, never()).checkTokenStatus(any()); } + @Test + void testUiSessionRejectsWorkspaceOverrideWithoutManagedValidation() throws Exception { + SubjectSum subject = mockSubject(); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn("team-a"); + when(request.getParameter("workspaceId")).thenReturn("team-b"); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); + verify(response).setStatus(403); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId()); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentAuthenticatedWorkspaceId()); + verify(accountService, never()).checkTokenStatus(any()); + } + } + + @Test + void testMissingSubjectWorkspaceRejectsRequestedWorkspace() throws Exception { + SubjectSum subject = mockSubject(); + when(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER)).thenReturn("team-b"); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); + verify(response).setStatus(403); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId()); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentAuthenticatedWorkspaceId()); + } + } + + @Test + void testUiSessionAllowsMatchingRequestedWorkspace() throws Exception { + SubjectSum subject = mockSubject(); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn("team-a"); + when(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER)).thenReturn(" team-a "); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + org.junit.jupiter.api.Assertions.assertEquals("team-a", AuthTokenRequestContext.currentWorkspaceId()); + org.junit.jupiter.api.Assertions.assertEquals( + "team-a", AuthTokenRequestContext.currentAuthenticatedWorkspaceId()); + } + } + + @Test + void testUiSessionRejectsSnakeCaseWorkspaceOverride() throws Exception { + SubjectSum subject = mockSubject(); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn("team-a"); + when(request.getParameter("workspace_id")).thenReturn("team-b"); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); + verify(response).setStatus(403); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId()); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentAuthenticatedWorkspaceId()); + } + } + @Test void testManagedTokenActivePassesThrough() throws Exception { - String managedToken = "managed-token"; when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), 7L)).thenReturn(null); doNothing().when(accountService).touchTokenLastUsedTime(managedToken); SubjectSum subject = mockManagedSubjectWithClaims(); + when(principalMap.getPrincipal(ObservabilityAccessTokenGateway.CLAIM_CREDENTIAL_VERSION)).thenReturn(7L); - assertTrue(preHandle(subject)); - verify(accountService).checkTokenStatus(managedToken); - verify(accountService).checkManagedTokenAccess("admin", List.of("admin")); - verify(accountService).touchTokenLastUsedTime(managedToken); + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID); + verify(accountService).checkManagedTokenAccess("admin", List.of("admin"), 7L); + verify(accountService).touchTokenLastUsedTime(managedToken); + } + } + + @ParameterizedTest + @ValueSource(strings = {"POST", "PUT"}) + void monitorManageWritesRequireApiAdminScopeExactlyOnce(String method) throws Exception { - String managedToken = "managed-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getMethod()).thenReturn(method); + when(request.getRequestURI()).thenReturn("/api/monitors/manage"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + doNothing().when(accountService).touchTokenLastUsedTime(managedToken); + SubjectSum subject = mockManagedSubjectWithClaims(); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID); + verify(accountService).checkManagedTokenAccess("admin", List.of("admin"), null); + verify(accountService).touchTokenLastUsedTime(managedToken); + } + } + + @Test + void testManagedTokenReadRequestRequiresReadonlyScope() throws Exception { - String managedToken = "managed-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getMethod()).thenReturn("GET"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.READONLY_QUERY, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + doNothing().when(accountService).touchTokenLastUsedTime(managedToken); + SubjectSum subject = mockManagedSubjectWithClaims(); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus( + managedToken, AuthTokenScopes.READONLY_QUERY, AuthTokenScopes.DEFAULT_WORKSPACE_ID); + } + } + + @ParameterizedTest + @ValueSource(strings = {"/api/logs/analysis/compare", "/api/logs/analysis/compare/other", "/api/monitors/manage"}) + void comparisonPostAloneUsesReadOnlyScope(String uri) throws Exception { + String required = "/api/logs/analysis/compare".equals(uri) ? AuthTokenScopes.READONLY_QUERY : AuthTokenScopes.API_ADMIN; - String managedToken = "managed-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn(uri); + when(accountService.checkTokenStatus( + managedToken, required, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + doNothing().when(accountService).touchTokenLastUsedTime(managedToken); + SubjectSum subject = mockManagedSubjectWithClaims(); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus( + managedToken, required, AuthTokenScopes.DEFAULT_WORKSPACE_ID); + } + } + + @Test + void testManagedTokenOtlpRequestRequiresIngestScope() throws Exception { - String managedToken = "managed-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getRequestURI()).thenReturn("/api/otlp/v1/metrics"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.OTLP_INGEST, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + doNothing().when(accountService).touchTokenLastUsedTime(managedToken); + SubjectSum subject = mockManagedSubjectWithClaims(); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus( + managedToken, AuthTokenScopes.OTLP_INGEST, AuthTokenScopes.DEFAULT_WORKSPACE_ID); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentCollectorId()); + } + } + + @Test + void testManagedCollectorTokenBindsIdentityForAllowedSignal() throws Exception { - String managedToken = "collector-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getRequestURI()).thenReturn("/api/otlp/v1/metrics"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.OTLP_INGEST, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + SubjectSum subject = mockManagedSubjectWithClaims(); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_TOKEN_AUDIENCE)) + .thenReturn(AuthTokenScopes.MANAGED_COLLECTOR_AUDIENCE); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_COLLECTOR_ID)).thenReturn("edge-west"); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_ALLOWED_SIGNALS)) + .thenReturn(List.of("metrics", "logs", "traces")); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); + org.junit.jupiter.api.Assertions.assertEquals("edge-west", AuthTokenRequestContext.currentCollectorId()); + + filter.afterCompletion(request, response, new Object(), null); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentCollectorId()); + } + } + + @Test + void testManagedCollectorTokenRejectsUnallowedSignal() throws Exception { - String managedToken = "collector-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getRequestURI()).thenReturn("/api/otlp/v1/traces"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.OTLP_INGEST, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + SubjectSum subject = mockManagedSubjectWithClaims(); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_TOKEN_AUDIENCE)) + .thenReturn(AuthTokenScopes.MANAGED_COLLECTOR_AUDIENCE); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_COLLECTOR_ID)).thenReturn("edge-west"); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_ALLOWED_SIGNALS)).thenReturn(List.of("metrics")); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); + verify(response).setStatus(401); + verify(accountService, never()).touchTokenLastUsedTime(managedToken); + } + } + + @Test + void testManagedTokenWorkspaceOverrideRejectedAfterTokenBoundaryValidation() throws Exception { - String managedToken = "managed-token"; + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getHeader(AuthTokenScopes.WORKSPACE_ID_HEADER)).thenReturn("prod-west"); + when(principalMap.getPrincipal(AuthTokenScopes.CLAIM_WORKSPACE_ID)).thenReturn("team-a"); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN, "team-a")).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + SubjectSum subject = mockManagedSubjectWithClaims(); + + try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { + mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + + org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); + verify(accountService).checkTokenStatus(managedToken, AuthTokenScopes.API_ADMIN, "team-a"); + verify(accountService).checkManagedTokenAccess("admin", List.of("admin"), null); + verify(accountService, never()).touchTokenLastUsedTime(managedToken); + verify(response).setStatus(403); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentWorkspaceId()); + org.junit.jupiter.api.Assertions.assertNull(AuthTokenRequestContext.currentAuthenticatedWorkspaceId()); + } } @Test void testManagedTokenRevokedRejected() throws Exception { - String revokedToken = "revoked-token"; - when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + revokedToken); + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn("Token has been revoked"); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( - revokedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) + .thenReturn("Token has been revoked"); + mockErrorWriter(); + SubjectSum subject = mockManagedSubject(); - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - when(response.getWriter()).thenReturn(printWriter); + assertFalse(preHandle(subject)); + verify(response).setStatus(401); + } + + @Test + void testManagedTokenInQueryParameterRevokedRejected() throws Exception { + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn(null); + when(request.getParameter("token")).thenReturn(managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn("Token has been revoked"); ++ when(accountService.checkTokenStatus( ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn("Token has been revoked"); + mockErrorWriter(); SubjectSum subject = mockManagedSubject(); - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + assertFalse(preHandle(subject)); + verify(response).setStatus(401); + } - org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); - verify(response).setStatus(401); - } + @Test + void testManagedTokenInQueryParameterActivePassesThrough() throws Exception { + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn(null); + when(request.getParameter("token")).thenReturn(managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); ++ when(accountService.checkTokenStatus( ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn(null); ++ when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + SubjectSum subject = mockManagedSubjectWithClaims(); + + assertTrue(preHandle(subject)); + verify(accountService).touchTokenLastUsedTime(managedToken); + } + + @Test + void testRevokedQueryTokenRejectedEvenWithActiveHeaderToken() throws Exception { + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + otherManagedToken); + when(request.getParameter("token")).thenReturn(managedToken); - when(accountService.checkTokenStatus(otherManagedToken)).thenReturn(null); - when(accountService.checkTokenStatus(managedToken)).thenReturn("Token has been revoked"); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); ++ when(accountService.checkTokenStatus( ++ otherManagedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn(null); ++ when(accountService.checkTokenStatus( ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn("Token has been revoked"); ++ when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + mockErrorWriter(); + SubjectSum subject = mockManagedSubjectWithClaims(); + + assertFalse(preHandle(subject)); + verify(response).setStatus(401); + } + + @Test + void testHeaderTokenExtractedLikeSurenessRejectsRevokedToken() throws Exception { + // Sureness strips every "Bearer" from the header, so a trailing "Bearer" still authenticates the token + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken + "Bearer"); + when(request.getParameter("token")).thenReturn(otherManagedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn("Token has been revoked"); ++ when(accountService.checkTokenStatus( ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn("Token has been revoked"); + mockErrorWriter(); + SubjectSum subject = mockManagedSubject(); + + assertFalse(preHandle(subject)); + verify(response).setStatus(401); - verify(accountService, never()).checkTokenStatus(otherManagedToken); ++ verify(accountService, never()).checkTokenStatus( ++ org.mockito.ArgumentMatchers.eq(otherManagedToken), any(), any()); + } + + @Test + void testNonManagedCandidatesAreIgnored() throws Exception { + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); + when(request.getParameter("token")).thenReturn(legacyToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); ++ when(accountService.checkTokenStatus( ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ .thenReturn(null); ++ when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); + SubjectSum subject = mockManagedSubjectWithClaims(); + + assertTrue(preHandle(subject)); - verify(accountService, never()).checkTokenStatus(legacyToken); ++ verify(accountService, never()).checkTokenStatus( ++ org.mockito.ArgumentMatchers.eq(legacyToken), any(), any()); } @Test void testManagedTokenStatusCheckFailureRejectsRequest() throws Exception { - when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer managed-token"); + when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenThrow(new RuntimeException("DB down")); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( - "managed-token", AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) ++ managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)) + .thenThrow(new RuntimeException("DB down")); - - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - when(response.getWriter()).thenReturn(printWriter); + mockErrorWriter(); SubjectSum subject = mockManagedSubject(); - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); - - org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); - verify(response).setStatus(503); - verify(accountService, never()).touchTokenLastUsedTime(any()); - } + assertFalse(preHandle(subject)); + verify(response).setStatus(503); + verify(accountService, never()).touchTokenLastUsedTime(any()); } @Test void testManagedTokenOutdatedRolesRejected() throws Exception { - String managedToken = "managed-token"; when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))) + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)) .thenReturn("Token permissions are outdated"); - - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - when(response.getWriter()).thenReturn(printWriter); + mockErrorWriter(); SubjectSum subject = mockManagedSubjectWithClaims(); - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); - - org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); - verify(response).setStatus(401); - verify(accountService, never()).touchTokenLastUsedTime(managedToken); - } - } - - @Test - void testEmptyBearerTokenPassesThrough() throws Exception { - when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer "); - SubjectSum subject = mockManagedSubject(); - - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); - - org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response, new Object())); - } + assertFalse(preHandle(subject)); + verify(response).setStatus(401); + verify(accountService, never()).touchTokenLastUsedTime(managedToken); } @Test void testTouchLastUsedTimeFailureDoesNotRejectRequest() throws Exception { - String managedToken = "managed-token"; when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)).thenReturn(null); // touchTokenLastUsedTime throws exception org.mockito.Mockito.doThrow(new RuntimeException("DB error")) .when(accountService).touchTokenLastUsedTime(managedToken); @@@ -492,26 -266,22 +547,25 @@@ @Test void testManagedTokenAccountValidationFailureRejectsRequest() throws Exception { - String managedToken = "managed-token"; when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " + managedToken); - when(accountService.checkTokenStatus(managedToken)).thenReturn(null); - when(accountService.checkManagedTokenAccess("admin", List.of("admin"))) + when(request.getMethod()).thenReturn("POST"); + when(request.getRequestURI()).thenReturn("/api/monitor"); + when(accountService.checkTokenStatus( + managedToken, AuthTokenScopes.API_ADMIN, AuthTokenScopes.DEFAULT_WORKSPACE_ID)).thenReturn(null); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"), null)) .thenThrow(new RuntimeException("account store unavailable")); - - StringWriter stringWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(stringWriter); - when(response.getWriter()).thenReturn(printWriter); + mockErrorWriter(); SubjectSum subject = mockManagedSubjectWithClaims(); - try (var mockedStatic = mockStatic(SurenessContextHolder.class)) { - mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + assertFalse(preHandle(subject)); + verify(response).setStatus(503); + verify(accountService, never()).touchTokenLastUsedTime(managedToken); + } - org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request, response, new Object())); - verify(response).setStatus(503); - verify(accountService, never()).touchTokenLastUsedTime(managedToken); + private boolean preHandle(SubjectSum subject) throws Exception { + try (MockedStatic<SurenessContextHolder> holder = mockStatic(SurenessContextHolder.class)) { + holder.when(SurenessContextHolder::getBindSubject).thenReturn(subject); + return filter.preHandle(request, response, new Object()); } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
