diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/ErrorCode.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/ErrorCode.java index 1450aefcf1..c5dc561e97 100644 --- a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/ErrorCode.java +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/ErrorCode.java @@ -48,8 +48,7 @@ public enum ErrorCode { PERMISSION_DENIED(FORBIDDEN), UNKNOWN_MIGRATION(NOT_FOUND), INVALID_QUERY(BAD_REQUEST), - IMPLEMENTATION_ABSENT(NOT_FOUND), - READ_ONLY_MODE(INTERNAL_SERVER_ERROR); + IMPLEMENTATION_ABSENT(NOT_FOUND); private final int httpCode; diff --git a/hermes-api/src/main/java/pl/allegro/tech/hermes/api/endpoints/ModeEndpoint.java b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/endpoints/ModeEndpoint.java new file mode 100644 index 0000000000..b301015473 --- /dev/null +++ b/hermes-api/src/main/java/pl/allegro/tech/hermes/api/endpoints/ModeEndpoint.java @@ -0,0 +1,27 @@ +package pl.allegro.tech.hermes.api.endpoints; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import pl.allegro.tech.hermes.api.BlacklistStatus; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; + +@Path("mode") +public interface ModeEndpoint { + + @GET + @Produces(APPLICATION_JSON) + String getMode(); + + @POST + @Produces(APPLICATION_JSON) + Response setMode(String mode); +} diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ModeEndpoint.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ModeEndpoint.java index 192f43cdfe..6bcb967687 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ModeEndpoint.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ModeEndpoint.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Component; import pl.allegro.tech.hermes.management.api.auth.Roles; import pl.allegro.tech.hermes.management.domain.mode.ModeService; +import pl.allegro.tech.hermes.management.domain.mode.ModeService.ManagementMode; +import static pl.allegro.tech.hermes.management.domain.mode.ModeService.ManagementMode.*; import javax.annotation.security.RolesAllowed; import javax.ws.rs.Consumes; @@ -13,6 +15,7 @@ import javax.ws.rs.HttpMethod; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; @@ -37,13 +40,20 @@ public String getMode() { } @POST - @Consumes(APPLICATION_JSON) @Produces(APPLICATION_JSON) - @ApiOperation(value = "Change management mode", response = String.class, httpMethod = HttpMethod.POST) + @ApiOperation(value = "Set management mode", response = String.class, httpMethod = HttpMethod.POST) @RolesAllowed(Roles.ADMIN) - public Response switchMode(@Context SecurityContext securityContext) { - modeService.toggleMode(); - + public Response setMode(@PathParam("mode") String mode) { + switch (mode) { + case "readWrite": + modeService.setMode(READ_WRITE); + break; + case "readOnly": + modeService.setMode(READ_ONLY); + break; + default: + return Response.status(Response.Status.BAD_REQUEST).build(); + } return Response.status(Response.Status.OK).build(); } } diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ReadOnlyFilter.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ReadOnlyFilter.java new file mode 100644 index 0000000000..d2be1ef79c --- /dev/null +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/api/ReadOnlyFilter.java @@ -0,0 +1,69 @@ +package pl.allegro.tech.hermes.management.api; + +import static javax.servlet.http.HttpServletResponse.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.filter.GenericFilterBean; +import pl.allegro.tech.hermes.management.domain.mode.ModeService; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +@WebFilter(urlPatterns = "/*") +public class ReadOnlyFilter extends GenericFilterBean { + + private static final Logger logger = LoggerFactory.getLogger(ReadOnlyFilter.class); + private static final String READ_ONLY_ERROR_MESSAGE = "Action forbidden due to read-only mode"; + private static final Set whitelist = new HashSet<>(); + + private final ModeService modeService; + + public ReadOnlyFilter(ModeService modeService) { + this.modeService = modeService; + + initWhitelist(); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + + if (modeService.isReadOnlyEnabled()) { + if (!req.getMethod().equals("GET") && !isWhitelisted(req.getRequestURI())) { + HttpServletResponse resp = ((HttpServletResponse) response); + resp.sendError(SC_SERVICE_UNAVAILABLE, READ_ONLY_ERROR_MESSAGE); + return; + } + } + chain.doFilter(request, response); + } + + private boolean isWhitelisted(String requestURI) { + if (requestURI.startsWith("/query")) { + return true; + } + if (requestURI.startsWith("/mode")) { + return true; + } + return false; + } + + private void initWhitelist() { + whitelist.add("/topics/query"); + whitelist.add("topics/{topicName}/subscriptions/query"); + whitelist.add("query/groups"); + whitelist.add("query/topics"); + whitelist.add("query/topics"); + whitelist.add("query/subscriptions"); + whitelist.add("query/topics/metrics"); + whitelist.add("query/subscriptions/metrics"); + } +} diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/ManagementConfiguration.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/ManagementConfiguration.java index 27fb486320..cfcc575c74 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/ManagementConfiguration.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/config/ManagementConfiguration.java @@ -6,15 +6,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import static javax.servlet.DispatcherType.REQUEST; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import pl.allegro.tech.hermes.common.clock.ClockFactory; +import pl.allegro.tech.hermes.management.api.ReadOnlyFilter; +import pl.allegro.tech.hermes.management.domain.mode.ModeService; import pl.allegro.tech.hermes.management.domain.subscription.SubscriptionLagSource; import pl.allegro.tech.hermes.management.infrastructure.metrics.NoOpSubscriptionLagSource; +import javax.servlet.DispatcherType; import java.time.Clock; +import java.util.EnumSet; @Configuration @EnableConfigurationProperties({TopicProperties.class, MetricsProperties.class, HttpClientProperties.class}) @@ -46,4 +52,13 @@ public SubscriptionLagSource consumerLagSource() { public Clock clock() { return new ClockFactory().provide(); } + + @Bean + public FilterRegistrationBean readOnlyFilter(ModeService modeService) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setDispatcherTypes(REQUEST); + registrationBean.setFilter(new ReadOnlyFilter(modeService)); + registrationBean.addUrlPatterns("/*"); + return registrationBean; + } } diff --git a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/mode/ModeService.java b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/mode/ModeService.java index 9354d5fa86..18ba31f7eb 100644 --- a/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/mode/ModeService.java +++ b/hermes-management/src/main/java/pl/allegro/tech/hermes/management/domain/mode/ModeService.java @@ -5,29 +5,41 @@ @Component public class ModeService { public enum ManagementMode { - NORMAL, READ_ONLY + READ_WRITE("readWrite"), READ_ONLY("readOnly"); + + private final String text; + + ManagementMode(String text) { + this.text = text; + } + + public String getText() { + return text; + } } - private volatile ManagementMode mode = ManagementMode.NORMAL; + private volatile ManagementMode mode = ManagementMode.READ_WRITE; public ManagementMode getMode() { return mode; } + public void setMode(ManagementMode mode) { + this.mode = mode; + } + public void toggleMode() { switch (mode) { - case NORMAL: + case READ_WRITE: mode = ManagementMode.READ_ONLY; break; case READ_ONLY: - mode = ManagementMode.NORMAL; + mode = ManagementMode.READ_WRITE; break; } } - public void mustBeNormal() { - if (mode != ManagementMode.NORMAL) { - throw new ReadOnlyModeException(); - } + public boolean isReadOnlyEnabled() { + return mode == ManagementMode.READ_WRITE; } } diff --git a/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/client/Hermes.java b/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/client/Hermes.java index 05a0928f20..98d2a5d4e6 100644 --- a/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/client/Hermes.java +++ b/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/client/Hermes.java @@ -7,6 +7,7 @@ import pl.allegro.tech.hermes.api.endpoints.BlacklistEndpoint; import pl.allegro.tech.hermes.api.endpoints.GroupEndpoint; import pl.allegro.tech.hermes.api.endpoints.MigrationEndpoint; +import pl.allegro.tech.hermes.api.endpoints.ModeEndpoint; import pl.allegro.tech.hermes.api.endpoints.OAuthProviderEndpoint; import pl.allegro.tech.hermes.api.endpoints.SubscriptionOwnershipEndpoint; import pl.allegro.tech.hermes.api.endpoints.OwnerEndpoint; @@ -114,6 +115,10 @@ public UnhealthyEndpoint unhealthyEndpoint() { return createProxy(url, UnhealthyEndpoint.class, managementConfig); } + public ModeEndpoint modeEndpoint() { + return createProxy(url, ModeEndpoint.class, managementConfig); + } + public BlacklistEndpoint createBlacklistEndpoint() { return createProxy(url, BlacklistEndpoint.class, managementConfig); } diff --git a/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/endpoint/HermesEndpoints.java b/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/endpoint/HermesEndpoints.java index 3e1ca7182c..88af125f22 100644 --- a/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/endpoint/HermesEndpoints.java +++ b/hermes-test-helper/src/main/java/pl/allegro/tech/hermes/test/helper/endpoint/HermesEndpoints.java @@ -4,6 +4,7 @@ import pl.allegro.tech.hermes.api.endpoints.BlacklistEndpoint; import pl.allegro.tech.hermes.api.endpoints.GroupEndpoint; import pl.allegro.tech.hermes.api.endpoints.MigrationEndpoint; +import pl.allegro.tech.hermes.api.endpoints.ModeEndpoint; import pl.allegro.tech.hermes.api.endpoints.OAuthProviderEndpoint; import pl.allegro.tech.hermes.api.endpoints.OwnerEndpoint; import pl.allegro.tech.hermes.api.endpoints.QueryEndpoint; @@ -41,7 +42,9 @@ public class HermesEndpoints { private final MigrationEndpoint migrationEndpoint; - public final UnhealthyEndpoint unhealthyEndpoint; + private final UnhealthyEndpoint unhealthyEndpoint; + + private final ModeEndpoint modeEndpoint; public HermesEndpoints(Hermes hermes) { this.groupEndpoint = hermes.createGroupEndpoint(); @@ -56,6 +59,7 @@ public HermesEndpoints(Hermes hermes) { this.ownerEndpoint = hermes.createOwnerEndpoint(); this.migrationEndpoint = hermes.createMigrationEndpoint(); this.unhealthyEndpoint = hermes.unhealthyEndpoint(); + this.modeEndpoint = hermes.modeEndpoint(); } public HermesEndpoints(String hermesFrontendUrl, String consumerUrl) { @@ -108,6 +112,10 @@ public BlacklistEndpoint blacklist() { return blacklistEndpoint; } + public ModeEndpoint modeEndpoint() { + return modeEndpoint; + } + public List findTopics(Topic topic, boolean tracking) { return topicEndpoint.list(topic.getName().getGroupName(), tracking); } diff --git a/integration/src/integration/java/pl/allegro/tech/hermes/integration/management/ReadOnlyModeTest.java b/integration/src/integration/java/pl/allegro/tech/hermes/integration/management/ReadOnlyModeTest.java new file mode 100644 index 0000000000..e956c524b2 --- /dev/null +++ b/integration/src/integration/java/pl/allegro/tech/hermes/integration/management/ReadOnlyModeTest.java @@ -0,0 +1,73 @@ +package pl.allegro.tech.hermes.integration.management; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import pl.allegro.tech.hermes.api.Group; +import pl.allegro.tech.hermes.integration.IntegrationTest; +import static pl.allegro.tech.hermes.integration.test.HermesAssertions.assertThat; +import static pl.allegro.tech.hermes.management.domain.mode.ModeService.ManagementMode.READ_ONLY; +import static pl.allegro.tech.hermes.management.domain.mode.ModeService.ManagementMode.READ_WRITE; +import static pl.allegro.tech.hermes.test.helper.builder.GroupBuilder.group; + +import javax.ws.rs.core.Response; + +public class ReadOnlyModeTest extends IntegrationTest { + + @BeforeMethod + public void initialize() { + management.modeEndpoint().setMode(READ_WRITE.getText()); + } + + @Test + public void shouldAllowNonModifyingOperations() { + // given + management.modeEndpoint().setMode(READ_WRITE.getText()); + String groupName = "kek"; + + // when + Response response = createGroup(groupName); + + // then + assertThat(response).hasStatus(Response.Status.CREATED); + } + + @Test + public void shouldRestrictModifyingOperations() { + // given + management.modeEndpoint().setMode(READ_ONLY.getText()); + String groupName = "lol"; + + // when + Response response = createGroup(groupName); + + // then + assertThat(response).hasStatus(Response.Status.SERVICE_UNAVAILABLE); + } + + @Test + public void shouldSwitchModeBack() { + // given + management.modeEndpoint().setMode(READ_ONLY.getText()); + String groupName = "mda"; + + // when + Response response = createGroup(groupName); + + // then + assertThat(response).hasStatus(Response.Status.SERVICE_UNAVAILABLE); + + // and + management.modeEndpoint().setMode(READ_WRITE.getText()); + + // when + response = createGroup(groupName); + + // then + assertThat(response).hasStatus(Response.Status.CREATED); + } + + public Response createGroup(String groupName) { + Group group = group(groupName).build(); + return management.group().create(group); + } +}