support REST deprecation by platform for all requests with % rollout
This commit is contained in:
parent
36439b5252
commit
2a7551cca5
|
@ -64,7 +64,7 @@ public class DynamicConfiguration {
|
||||||
|
|
||||||
@JsonProperty
|
@JsonProperty
|
||||||
@Valid
|
@Valid
|
||||||
Map<ClientPlatform, Semver> minimumRestFreeVersion = Map.of();
|
DynamicRestDeprecationConfiguration restDeprecation = new DynamicRestDeprecationConfiguration(Map.of());
|
||||||
|
|
||||||
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
|
public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(
|
||||||
final String experimentName) {
|
final String experimentName) {
|
||||||
|
@ -112,8 +112,8 @@ public class DynamicConfiguration {
|
||||||
return svrStatusCodesToIgnoreForAccountDeletion;
|
return svrStatusCodesToIgnoreForAccountDeletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<ClientPlatform, Semver> minimumRestFreeVersion() {
|
public DynamicRestDeprecationConfiguration restDeprecation() {
|
||||||
return minimumRestFreeVersion;
|
return restDeprecation;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<ClientPlatform, PlatformConfiguration> platforms) {
|
||||||
|
public record PlatformConfiguration(Semver minimumRestFreeVersion, int universalRolloutPercent) {}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.filters;
|
package org.whispersystems.textsecuregcm.filters;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import com.google.common.net.HttpHeaders;
|
import com.google.common.net.HttpHeaders;
|
||||||
import com.vdurmont.semver4j.Semver;
|
import com.vdurmont.semver4j.Semver;
|
||||||
import io.micrometer.core.instrument.Metrics;
|
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.container.ContainerRequestFilter;
|
||||||
import jakarta.ws.rs.core.SecurityContext;
|
import jakarta.ws.rs.core.SecurityContext;
|
||||||
import java.io.IOException;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||||
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
|
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.experiment.ExperimentEnrollmentManager;
|
||||||
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
|
||||||
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
|
||||||
|
@ -29,51 +33,48 @@ import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;
|
||||||
|
|
||||||
public class RestDeprecationFilter implements ContainerRequestFilter {
|
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 String DEPRECATED_REST_COUNTER_NAME = MetricsUtil.name(RestDeprecationFilter.class, "blockedRestRequest");
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class);
|
private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class);
|
||||||
|
|
||||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
|
||||||
final ExperimentEnrollmentManager experimentEnrollmentManager;
|
final ExperimentEnrollmentManager experimentEnrollmentManager;
|
||||||
|
final Supplier<Random> random;
|
||||||
|
|
||||||
public RestDeprecationFilter(
|
public RestDeprecationFilter(
|
||||||
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||||
final ExperimentEnrollmentManager experimentEnrollmentManager) {
|
final ExperimentEnrollmentManager experimentEnrollmentManager) {
|
||||||
|
this(dynamicConfigurationManager, experimentEnrollmentManager, ThreadLocalRandom::current);
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
public RestDeprecationFilter(
|
||||||
|
final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
|
||||||
|
final ExperimentEnrollmentManager experimentEnrollmentManager,
|
||||||
|
final Supplier<Random> random) {
|
||||||
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
this.dynamicConfigurationManager = dynamicConfigurationManager;
|
||||||
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
this.experimentEnrollmentManager = experimentEnrollmentManager;
|
||||||
|
this.random = random;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void filter(final ContainerRequestContext requestContext) throws IOException {
|
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<ClientPlatform, Semver> minimumRestFreeVersion = dynamicConfigurationManager.getConfiguration().minimumRestFreeVersion();
|
|
||||||
final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT);
|
final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);
|
||||||
final ClientPlatform platform = userAgent.platform();
|
final ClientPlatform platform = userAgent.platform();
|
||||||
final Semver version = userAgent.version();
|
final Semver version = userAgent.version();
|
||||||
if (!minimumRestFreeVersion.containsKey(platform)) {
|
final PlatformConfiguration config = dynamicConfigurationManager.getConfiguration().restDeprecation().platforms().get(platform);
|
||||||
|
if (config == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (version.isGreaterThanOrEqualTo(minimumRestFreeVersion.get(platform))) {
|
if (!isEnrolled(requestContext, config.universalRolloutPercent())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (version.isGreaterThanOrEqualTo(config.minimumRestFreeVersion())) {
|
||||||
Metrics.counter(
|
Metrics.counter(
|
||||||
DEPRECATED_REST_COUNTER_NAME, Tags.of("platform", platform.name().toLowerCase(), "version", version.toString()))
|
DEPRECATED_REST_COUNTER_NAME, Tags.of("platform", platform.name().toLowerCase(), "version", version.toString()))
|
||||||
.increment();
|
.increment();
|
||||||
|
@ -83,4 +84,23 @@ public class RestDeprecationFilter implements ContainerRequestFilter {
|
||||||
return; // at present we're only interested in experimenting on known clients
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
package org.whispersystems.textsecuregcm.filters;
|
package org.whispersystems.textsecuregcm.filters;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
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.mock;
|
||||||
import static org.mockito.Mockito.when;
|
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.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.SecurityContext;
|
import jakarta.ws.rs.core.SecurityContext;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.util.Random;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.glassfish.jersey.server.ContainerRequest;
|
import org.glassfish.jersey.server.ContainerRequest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
@ -48,11 +50,13 @@ class RestDeprecationFilterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testOldClient() throws Exception {
|
void testOldClientAuthenticated() throws Exception {
|
||||||
final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(
|
final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(
|
||||||
"""
|
"""
|
||||||
minimumRestFreeVersion:
|
restDeprecation:
|
||||||
ANDROID: 200.0.0
|
platforms:
|
||||||
|
ANDROID:
|
||||||
|
minimumRestFreeVersion: 200.0.0
|
||||||
experiments:
|
experiments:
|
||||||
restDeprecation:
|
restDeprecation:
|
||||||
uuidEnrollmentPercentage: 100
|
uuidEnrollmentPercentage: 100
|
||||||
|
@ -73,12 +77,42 @@ class RestDeprecationFilterTest {
|
||||||
filter.filter(req);
|
filter.filter(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void testBlocking() throws Exception {
|
@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(
|
final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(
|
||||||
"""
|
"""
|
||||||
minimumRestFreeVersion:
|
restDeprecation:
|
||||||
ANDROID: 10.10.10
|
platforms:
|
||||||
|
ANDROID:
|
||||||
|
minimumRestFreeVersion: 200.0.0
|
||||||
|
universalRolloutPercent: 50
|
||||||
|
experiments:
|
||||||
|
restDeprecation:
|
||||||
|
uuidEnrollmentPercentage: 100
|
||||||
|
""",
|
||||||
|
DynamicConfiguration.class);
|
||||||
|
final DynamicConfigurationManager<DynamicConfiguration> 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:
|
experiments:
|
||||||
restDeprecation:
|
restDeprecation:
|
||||||
enrollmentPercentage: 100
|
enrollmentPercentage: 100
|
||||||
|
@ -108,4 +142,46 @@ class RestDeprecationFilterTest {
|
||||||
assertThrows(WebApplicationException.class, () -> filter.filter(req));
|
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<DynamicConfiguration> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue