Add anonymous health check endpoint

Besides the endpoint, another API was added, the ShouldRequestPassChecker.
This api should be used to check whether a REST Request should be passed or rejected.
This commit is contained in:
Thomas Zerr
2025-05-02 14:00:05 +02:00
parent af17663e45
commit 63a936d5e2
7 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Anonymous endpoint to check whether the SCM-Manager is healthy or not

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.security;
import jakarta.servlet.http.HttpServletRequest;
public interface ShouldRequestPassChecker {
boolean shouldPass(HttpServletRequest request);
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.web.VndMediaType;
@Path(HealthCheckResource.PATH)
public class HealthCheckResource {
public static final String PATH = "v2/health-check";
@GET
@Path("")
@Operation(
summary = "Get health check",
description = "Returns depending on the response code, whether the scm manager is up and running",
tags = "Health Check"
)
@ApiResponse(
responseCode = "200",
description = "success"
)
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
@AllowAnonymousAccess
public Response getHealthCheck() {
return Response.ok().build();
}
}

View File

@@ -25,6 +25,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.WebElementDescriptor;
import sonia.scm.security.DefaultShouldRequestPassChecker;
import sonia.scm.security.ShouldRequestPassChecker;
public class WebElementModule extends ServletModule {
@@ -59,6 +61,7 @@ public class WebElementModule extends ServletModule {
// filters must be in singleton scope
bind(clazz).in(Scopes.SINGLETON);
bind(ShouldRequestPassChecker.class).to(DefaultShouldRequestPassChecker.class);
WebElementDescriptor opts = filter.getDescriptor();
FilterKeyBindingBuilder builder;

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.security;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import sonia.scm.api.v2.resources.HealthCheckResource;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.UserAgentParser;
public class DefaultShouldRequestPassChecker implements ShouldRequestPassChecker {
private final ScmConfiguration configuration;
private final UserAgentParser userAgentParser;
@Inject
public DefaultShouldRequestPassChecker(ScmConfiguration configuration, UserAgentParser userAgentParser) {
this.configuration = configuration;
this.userAgentParser = userAgentParser;
}
@Override
public boolean shouldPass(HttpServletRequest request) {
return isUserAuthenticated()
|| isAnonymousProtocolRequest(request)
|| isMercurialHookRequest(request)
|| (!isLoginRequest(request) && isFullAnonymousAccessEnabled())
|| isHealthCheckRequest(request);
}
private boolean isUserAuthenticated() {
Subject subject = SecurityUtils.getSubject();
return subject.isAuthenticated() && !Authentications.isAuthenticatedSubjectAnonymous();
}
private boolean isAnonymousProtocolRequest(HttpServletRequest request) {
return !HttpUtil.isWUIRequest(request)
&& Authentications.isAuthenticatedSubjectAnonymous()
&& configuration.getAnonymousMode() == AnonymousMode.PROTOCOL_ONLY
&& !userAgentParser.parse(request).isBrowser();
}
private boolean isMercurialHookRequest(HttpServletRequest request) {
return request.getRequestURI().startsWith(request.getContextPath() + "/hook/hg/");
}
private boolean isLoginRequest(HttpServletRequest request) {
final String requestURI = request.getRequestURI();
final String contextPath = request.getContextPath();
return requestURI != null && requestURI.startsWith(contextPath + "/login");
}
private boolean isFullAnonymousAccessEnabled() {
return configuration.getAnonymousMode() == AnonymousMode.FULL;
}
private boolean isHealthCheckRequest(HttpServletRequest request) {
return request.getRequestURI().startsWith(request.getContextPath() + "/api/" + HealthCheckResource.PATH);
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.api.v2.resources;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import sonia.scm.web.RestDispatcher;
import java.net.URISyntaxException;
import static org.assertj.core.api.Assertions.assertThat;
class HealthCheckResourceTest {
private RestDispatcher dispatcher;
@BeforeEach
void setup() {
HealthCheckResource resource = new HealthCheckResource();
dispatcher = new RestDispatcher();
dispatcher.addSingletonResource(resource);
}
@Test
void shouldReturnPositiveHealthCheck() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/v2/health-check");
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.security;
import jakarta.servlet.http.HttpServletRequest;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.api.v2.resources.HealthCheckResource;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
class DefaultShouldRequestPassCheckerTest {
@Mock
private HttpServletRequest request;
@Mock
private UserAgentParser userAgentParser;
private ScmConfiguration configuration;
private DefaultShouldRequestPassChecker checker;
@BeforeEach
void setup() {
lenient().when(request.getContextPath()).thenReturn("/scm");
lenient().when(request.getRequestURI()).thenReturn("/scm/random/uri");
configuration = new ScmConfiguration();
checker = new DefaultShouldRequestPassChecker(configuration, userAgentParser);
}
@Test
@SubjectAware("Trainer Red")
void shouldPassBecauseUserIsAuthenticated() {
assertThat(checker.shouldPass(request)).isTrue();
}
@Test
@SubjectAware(Authentications.PRINCIPAL_ANONYMOUS)
void shouldNotPassBecauseUserIsAnonymous() {
assertThat(checker.shouldPass(request)).isFalse();
}
@Test
@SubjectAware(Authentications.PRINCIPAL_ANONYMOUS)
void shouldPassBecauseAnonymousProtocolRequestIsEnabled() {
configuration.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
when(userAgentParser.parse(request)).thenReturn(UserAgent.other("git").build());
assertThat(checker.shouldPass(request)).isTrue();
}
@Test
@SubjectAware(Authentications.PRINCIPAL_ANONYMOUS)
void shouldPassBecauseMercurialHookRequest() {
when(request.getContextPath()).thenReturn("/scm");
when(request.getRequestURI()).thenReturn("/scm/hook/hg/");
assertThat(checker.shouldPass(request)).isTrue();
}
@Test
@SubjectAware(Authentications.PRINCIPAL_ANONYMOUS)
void shouldPassBecauseFullAnonymousAccessIsEnabled() {
configuration.setAnonymousMode(AnonymousMode.FULL);
assertThat(checker.shouldPass(request)).isTrue();
}
@Test
@SubjectAware(Authentications.PRINCIPAL_ANONYMOUS)
void shouldPassBecauseRequestIsHealthCheck() {
when(request.getRequestURI()).thenReturn("/scm/api/" + HealthCheckResource.PATH);
assertThat(checker.shouldPass(request)).isTrue();
}
}