diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java index 01273c035..1e9dfe770 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java @@ -64,7 +64,7 @@ public class DynamicConfiguration { @JsonProperty @Valid - Map minimumRestFreeVersion = Map.of(); + DynamicRestDeprecationConfiguration restDeprecation = new DynamicRestDeprecationConfiguration(Map.of()); public Optional getExperimentEnrollmentConfiguration( final String experimentName) { @@ -112,8 +112,8 @@ public class DynamicConfiguration { return svrStatusCodesToIgnoreForAccountDeletion; } - public Map minimumRestFreeVersion() { - return minimumRestFreeVersion; + public DynamicRestDeprecationConfiguration restDeprecation() { + return restDeprecation; } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRestDeprecationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRestDeprecationConfiguration.java new file mode 100644 index 000000000..9cda78eda --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRestDeprecationConfiguration.java @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.configuration.dynamic; + +import com.vdurmont.semver4j.Semver; +import java.util.Map; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; + +public record DynamicRestDeprecationConfiguration(Map platforms) { + public record PlatformConfiguration(Semver minimumRestFreeVersion, int universalRolloutPercent) {} +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java index 595d32219..bb7a59e34 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java @@ -5,6 +5,7 @@ package org.whispersystems.textsecuregcm.filters; +import com.google.common.annotations.VisibleForTesting; import com.google.common.net.HttpHeaders; import com.vdurmont.semver4j.Semver; import io.micrometer.core.instrument.Metrics; @@ -14,11 +15,14 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.core.SecurityContext; import java.io.IOException; -import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRestDeprecationConfiguration.PlatformConfiguration; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.metrics.MetricsUtil; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; @@ -29,51 +33,48 @@ import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; public class RestDeprecationFilter implements ContainerRequestFilter { - private static final String EXPERIMENT_NAME = "restDeprecation"; + private static final String AUTHENTICATED_EXPERIMENT_NAME = "restDeprecation"; private static final String DEPRECATED_REST_COUNTER_NAME = MetricsUtil.name(RestDeprecationFilter.class, "blockedRestRequest"); private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class); final DynamicConfigurationManager dynamicConfigurationManager; final ExperimentEnrollmentManager experimentEnrollmentManager; + final Supplier random; public RestDeprecationFilter( final DynamicConfigurationManager dynamicConfigurationManager, final ExperimentEnrollmentManager experimentEnrollmentManager) { + this(dynamicConfigurationManager, experimentEnrollmentManager, ThreadLocalRandom::current); + } + + @VisibleForTesting + public RestDeprecationFilter( + final DynamicConfigurationManager dynamicConfigurationManager, + final ExperimentEnrollmentManager experimentEnrollmentManager, + final Supplier random) { this.dynamicConfigurationManager = dynamicConfigurationManager; this.experimentEnrollmentManager = experimentEnrollmentManager; + this.random = random; } @Override public void filter(final ContainerRequestContext requestContext) throws IOException { - final SecurityContext securityContext = requestContext.getSecurityContext(); - - if (securityContext == null || securityContext.getUserPrincipal() == null) { - // We can't check if an unauthenticated request is in the experiment - return; - } - - if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) { - if (!experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), EXPERIMENT_NAME)) { - return; - } - } else { - log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName()); - return; - } - - final Map minimumRestFreeVersion = dynamicConfigurationManager.getConfiguration().minimumRestFreeVersion(); final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT); try { final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); final ClientPlatform platform = userAgent.platform(); final Semver version = userAgent.version(); - if (!minimumRestFreeVersion.containsKey(platform)) { + final PlatformConfiguration config = dynamicConfigurationManager.getConfiguration().restDeprecation().platforms().get(platform); + if (config == null) { return; } - if (version.isGreaterThanOrEqualTo(minimumRestFreeVersion.get(platform))) { + if (!isEnrolled(requestContext, config.universalRolloutPercent())) { + return; + } + if (version.isGreaterThanOrEqualTo(config.minimumRestFreeVersion())) { Metrics.counter( DEPRECATED_REST_COUNTER_NAME, Tags.of("platform", platform.name().toLowerCase(), "version", version.toString())) .increment(); @@ -83,4 +84,23 @@ public class RestDeprecationFilter implements ContainerRequestFilter { return; // at present we're only interested in experimenting on known clients } } + + private boolean isEnrolled(final ContainerRequestContext requestContext, int universalRolloutPercent) { + if (random.get().nextInt(100) < universalRolloutPercent) { + return true; + } + + final SecurityContext securityContext = requestContext.getSecurityContext(); + + if (securityContext == null || securityContext.getUserPrincipal() == null) { + return false; + } + + if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice ad) { + return experimentEnrollmentManager.isEnrolled(ad.getAccount().getUuid(), AUTHENTICATED_EXPERIMENT_NAME); + } else { + log.error("Security context was not null but user principal was of type {}", securityContext.getUserPrincipal().getClass().getName()); + return false; + } + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java index 707d21a69..84f190101 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java @@ -6,6 +6,7 @@ package org.whispersystems.textsecuregcm.filters; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -13,6 +14,7 @@ import com.google.common.net.HttpHeaders; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.SecurityContext; import java.net.URI; +import java.util.Random; import java.util.UUID; import org.glassfish.jersey.server.ContainerRequest; import org.junit.jupiter.api.Test; @@ -48,11 +50,13 @@ class RestDeprecationFilterTest { } @Test - void testOldClient() throws Exception { + void testOldClientAuthenticated() throws Exception { final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( """ - minimumRestFreeVersion: - ANDROID: 200.0.0 + restDeprecation: + platforms: + ANDROID: + minimumRestFreeVersion: 200.0.0 experiments: restDeprecation: uuidEnrollmentPercentage: 100 @@ -73,12 +77,42 @@ class RestDeprecationFilterTest { filter.filter(req); } - @Test - void testBlocking() throws Exception { + @ParameterizedTest + @ValueSource(ints = {0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 99}) + void testOldClientUnauthenticated(int randomRoll) throws Exception { final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( """ - minimumRestFreeVersion: - ANDROID: 10.10.10 + restDeprecation: + platforms: + ANDROID: + minimumRestFreeVersion: 200.0.0 + universalRolloutPercent: 50 + experiments: + restDeprecation: + uuidEnrollmentPercentage: 100 + """, + DynamicConfiguration.class); + final DynamicConfigurationManager dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config); + final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + final Random fakeRandom = mock(Random.class); + when(fakeRandom.nextInt(anyInt())).thenReturn(randomRoll); + + final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager, () -> fakeRandom); + + final ContainerRequest req = new ContainerRequest(null, new URI("/some/uri"), "GET", null, null, null); + req.getHeaders().add(HttpHeaders.USER_AGENT, "Signal-Android/100.0.0"); + + filter.filter(req); + } + + @Test + void testBlockingAuthenticated() throws Exception { + final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( + """ + restDeprecation: + platforms: + ANDROID: + minimumRestFreeVersion: 10.10.10 experiments: restDeprecation: enrollmentPercentage: 100 @@ -108,4 +142,46 @@ class RestDeprecationFilterTest { assertThrows(WebApplicationException.class, () -> filter.filter(req)); } + @ParameterizedTest + @ValueSource(ints = {0, 10, 20, 30, 40, 50, 60, 69, 70, 71, 80, 90, 99}) + void testBlockingUnauthenticated(int randomRoll) throws Exception { + final DynamicConfiguration config = SystemMapper.yamlMapper().readValue( + """ + restDeprecation: + platforms: + ANDROID: + minimumRestFreeVersion: 10.10.10 + universalRolloutPercent: 70 + """, + DynamicConfiguration.class); + final DynamicConfigurationManager dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config); + final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); + final Random fakeRandom = mock(Random.class); + when(fakeRandom.nextInt(anyInt())).thenReturn(randomRoll); + + final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager, () -> fakeRandom); + + final ContainerRequest req = new ContainerRequest(null, new URI("/some/path"), "GET", null, null, null); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.9.15"); + filter.filter(req); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.10.9"); + filter.filter(req); + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/10.10.10"); + if (randomRoll < 70) { + assertThrows(WebApplicationException.class, () -> filter.filter(req)); + } else { + filter.filter(req); + } + + req.getHeaders().putSingle(HttpHeaders.USER_AGENT, "Signal-Android/100.0.0"); + if (randomRoll < 70) { + assertThrows(WebApplicationException.class, () -> filter.filter(req)); + } else { + filter.filter(req); + } + } + }