diff --git a/gradle/changelog/health-check.yaml b/gradle/changelog/health-check.yaml new file mode 100644 index 0000000000..b35ef27fb7 --- /dev/null +++ b/gradle/changelog/health-check.yaml @@ -0,0 +1,2 @@ +- type: added + description: Anonymous endpoint to check whether the SCM-Manager is healthy or not diff --git a/scm-core/src/main/java/sonia/scm/security/ShouldRequestPassChecker.java b/scm-core/src/main/java/sonia/scm/security/ShouldRequestPassChecker.java new file mode 100644 index 0000000000..719d11f06e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/ShouldRequestPassChecker.java @@ -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); +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckResource.java new file mode 100644 index 0000000000..9af7bf74fd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckResource.java @@ -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(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/filter/WebElementModule.java b/scm-webapp/src/main/java/sonia/scm/filter/WebElementModule.java index acb2ce8345..88f000e3e6 100644 --- a/scm-webapp/src/main/java/sonia/scm/filter/WebElementModule.java +++ b/scm-webapp/src/main/java/sonia/scm/filter/WebElementModule.java @@ -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; diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultShouldRequestPassChecker.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultShouldRequestPassChecker.java new file mode 100644 index 0000000000..4ab3e1ca4b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultShouldRequestPassChecker.java @@ -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); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HealthCheckResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HealthCheckResourceTest.java new file mode 100644 index 0000000000..be5ad574dd --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/HealthCheckResourceTest.java @@ -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); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/DefaultShouldRequestPassCheckerTest.java b/scm-webapp/src/test/java/sonia/scm/security/DefaultShouldRequestPassCheckerTest.java new file mode 100644 index 0000000000..e0dc9894c7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/DefaultShouldRequestPassCheckerTest.java @@ -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(); + } +}