diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java index f6e11ca8e..97ef203d1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java @@ -21,9 +21,12 @@ import javax.ws.rs.Produces; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.nio.ByteBuffer; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.List; +import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import io.dropwizard.auth.Auth; @@ -46,12 +49,12 @@ public class RemoteConfigController { public UserRemoteConfigList getAll(@Auth Account account) { try { MessageDigest digest = MessageDigest.getInstance("SHA1"); - byte[] number = account.getNumber().getBytes(); return new UserRemoteConfigList(remoteConfigsManager.getAll().stream().map(config -> new UserRemoteConfig(config.getName(), - isInBucket(digest, number, + isInBucket(digest, account.getUuid(), config.getName().getBytes(), - config.getPercentage()))) + config.getPercentage(), + config.getUuids()))) .collect(Collectors.toList())); } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); @@ -82,8 +85,14 @@ public class RemoteConfigController { } @VisibleForTesting - public static boolean isInBucket(MessageDigest digest, byte[] user, byte[] configName, int configPercentage) { - digest.update(user); + public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] configName, int configPercentage, Set uuidsInBucket) { + if (uuidsInBucket.contains(uid)) return true; + + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uid.getMostSignificantBits()); + bb.putLong(uid.getLeastSignificantBits()); + + digest.update(bb.array()); byte[] hash = digest.digest(configName); int bucket = (int)(Math.abs(Conversions.byteArrayToLong(hash)) % 100); @@ -93,7 +102,7 @@ public class RemoteConfigController { @SuppressWarnings("BooleanMethodIsAlwaysInverted") private boolean isAuthorized(String configToken) { - return configAuthTokens.stream().anyMatch(authorized -> MessageDigest.isEqual(authorized.getBytes(), configToken.getBytes())); + return configToken != null && configAuthTokens.stream().anyMatch(authorized -> MessageDigest.isEqual(authorized.getBytes(), configToken.getBytes())); } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java index f1e9d8065..facedbcec 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java @@ -6,6 +6,11 @@ import javax.validation.constraints.Max; import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.UUID; public class RemoteConfig { @@ -19,11 +24,16 @@ public class RemoteConfig { @Max(100) private int percentage; + @JsonProperty + @NotNull + private Set uuids = new HashSet<>(); + public RemoteConfig() {} - public RemoteConfig(String name, int percentage) { + public RemoteConfig(String name, int percentage, Set uuids) { this.name = name; this.percentage = percentage; + this.uuids = uuids; } public int getPercentage() { @@ -33,4 +43,8 @@ public class RemoteConfig { public String getName() { return name; } + + public Set getUuids() { + return uuids; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java index 792d96cf3..fe692b5ad 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java @@ -11,6 +11,7 @@ import org.whispersystems.textsecuregcm.storage.mappers.RemoteConfigRowMapper; import org.whispersystems.textsecuregcm.util.Constants; import org.whispersystems.textsecuregcm.util.SystemMapper; +import java.util.LinkedList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -22,8 +23,7 @@ public class RemoteConfigs { public static final String ID = "id"; public static final String NAME = "name"; public static final String PERCENTAGE = "percentage"; - - private static final ObjectMapper mapper = SystemMapper.getMapper(); + public static final String UUIDS = "uuids"; private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); private final Timer setTimer = metricRegistry.timer(name(Accounts.class, "set" )); @@ -35,14 +35,16 @@ public class RemoteConfigs { public RemoteConfigs(FaultTolerantDatabase database) { this.database = database; this.database.getDatabase().registerRowMapper(new RemoteConfigRowMapper()); + this.database.getDatabase().registerArrayType(UUID.class, "uuid"); } public void set(RemoteConfig remoteConfig) { database.use(jdbi -> jdbi.useHandle(handle -> { try (Timer.Context ignored = setTimer.time()) { - handle.createUpdate("INSERT INTO remote_config (" + NAME + ", " + PERCENTAGE + ") VALUES (:name, :percentage) ON CONFLICT(" + NAME + ") DO UPDATE SET " + PERCENTAGE + " = EXCLUDED." + PERCENTAGE) + handle.createUpdate("INSERT INTO remote_config (" + NAME + ", " + PERCENTAGE + ", " + UUIDS + ") VALUES (:name, :percentage, :uuids) ON CONFLICT(" + NAME + ") DO UPDATE SET " + PERCENTAGE + " = EXCLUDED." + PERCENTAGE + ", " + UUIDS + " = EXCLUDED." + UUIDS) .bind("name", remoteConfig.getName()) .bind("percentage", remoteConfig.getPercentage()) + .bind("uuids", remoteConfig.getUuids().toArray(new UUID[0])) .execute(); } })); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java index 817f05239..46b8d5428 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/mappers/RemoteConfigRowMapper.java @@ -7,12 +7,15 @@ import org.whispersystems.textsecuregcm.storage.RemoteConfigs; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.UUID; public class RemoteConfigRowMapper implements RowMapper { @Override public RemoteConfig map(ResultSet rs, StatementContext ctx) throws SQLException { - return new RemoteConfig(rs.getString(RemoteConfigs.NAME), rs.getInt(RemoteConfigs.PERCENTAGE)); + return new RemoteConfig(rs.getString(RemoteConfigs.NAME), rs.getInt(RemoteConfigs.PERCENTAGE), new HashSet<>(Arrays.asList((UUID[])rs.getArray(RemoteConfigs.UUIDS).getArray()))); } } diff --git a/service/src/main/resources/accountsdb.xml b/service/src/main/resources/accountsdb.xml index 75a1a0e6b..1b38dc76c 100644 --- a/service/src/main/resources/accountsdb.xml +++ b/service/src/main/resources/accountsdb.xml @@ -287,7 +287,7 @@ - + diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java index c33a62a70..d117cfe22 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java @@ -22,9 +22,11 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.UUID; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit.ResourceTestRule; @@ -52,9 +54,22 @@ public class RemoteConfigControllerTest { @Before public void setup() throws Exception { when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{ - add(new RemoteConfig("android.stickers", 25)); - add(new RemoteConfig("ios.stickers", 50)); - add(new RemoteConfig("always.true", 100)); + add(new RemoteConfig("android.stickers", 25, new HashSet<>() {{ + add(AuthHelper.DISABLED_UUID); + add(AuthHelper.INVALID_UUID); + }})); + + add(new RemoteConfig("ios.stickers", 50, new HashSet<>() {{ + + }})); + + add(new RemoteConfig("always.true", 100, new HashSet<>() {{ + + }})); + + add(new RemoteConfig("only.special", 0, new HashSet<>() {{ + add(AuthHelper.VALID_UUID); + }})); }}); } @@ -68,13 +83,33 @@ public class RemoteConfigControllerTest { verify(remoteConfigsManager, times(1)).getAll(); - assertThat(configuration.getConfig().size()).isEqualTo(3); + assertThat(configuration.getConfig().size()).isEqualTo(4); assertThat(configuration.getConfig().get(0).getName()).isEqualTo("android.stickers"); assertThat(configuration.getConfig().get(1).getName()).isEqualTo("ios.stickers"); assertThat(configuration.getConfig().get(2).getName()).isEqualTo("always.true"); assertThat(configuration.getConfig().get(2).isEnabled()).isEqualTo(true); + assertThat(configuration.getConfig().get(3).isEnabled()).isEqualTo(true); } + @Test + public void testRetrieveConfigNotSpecial() { + UserRemoteConfigList configuration = resources.getJerseyTest() + .target("/v1/config/") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_NUMBER_TWO, AuthHelper.VALID_PASSWORD_TWO)) + .get(UserRemoteConfigList.class); + + verify(remoteConfigsManager, times(1)).getAll(); + + assertThat(configuration.getConfig().size()).isEqualTo(4); + assertThat(configuration.getConfig().get(0).getName()).isEqualTo("android.stickers"); + assertThat(configuration.getConfig().get(1).getName()).isEqualTo("ios.stickers"); + assertThat(configuration.getConfig().get(2).getName()).isEqualTo("always.true"); + assertThat(configuration.getConfig().get(2).isEnabled()).isEqualTo(true); + assertThat(configuration.getConfig().get(3).isEnabled()).isEqualTo(false); + } + + @Test public void testRetrieveConfigUnauthorized() { Response response = resources.getJerseyTest() @@ -95,7 +130,7 @@ public class RemoteConfigControllerTest { .target("/v1/config") .request() .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android.stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + .put(Entity.entity(new RemoteConfig("android.stickers", 88, new HashSet<>()), MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(204); @@ -105,6 +140,7 @@ public class RemoteConfigControllerTest { assertThat(captor.getValue().getName()).isEqualTo("android.stickers"); assertThat(captor.getValue().getPercentage()).isEqualTo(88); + assertThat(captor.getValue().getUuids()).isEmpty(); } @Test @@ -113,7 +149,19 @@ public class RemoteConfigControllerTest { .target("/v1/config") .request() .header("Config-Token", "baz") - .put(Entity.entity(new RemoteConfig("android.stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + .put(Entity.entity(new RemoteConfig("android.stickers", 88, new HashSet<>()), MediaType.APPLICATION_JSON_TYPE)); + + assertThat(response.getStatus()).isEqualTo(401); + + verifyNoMoreInteractions(remoteConfigsManager); + } + + @Test + public void testSetConfigMissingUnauthorized() { + Response response = resources.getJerseyTest() + .target("/v1/config") + .request() + .put(Entity.entity(new RemoteConfig("android.stickers", 88, new HashSet<>()), MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(401); @@ -126,7 +174,7 @@ public class RemoteConfigControllerTest { .target("/v1/config") .request() .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android-stickers", 88), MediaType.APPLICATION_JSON_TYPE)); + .put(Entity.entity(new RemoteConfig("android-stickers", 88, new HashSet<>()), MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(422); @@ -139,7 +187,7 @@ public class RemoteConfigControllerTest { .target("/v1/config") .request() .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("", 88), MediaType.APPLICATION_JSON_TYPE)); + .put(Entity.entity(new RemoteConfig("", 88, new HashSet<>()), MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(422); @@ -186,7 +234,7 @@ public class RemoteConfigControllerTest { int count = enabledMap.getOrDefault(config.getName(), 0); int random = new SecureRandom().nextInt(iterations); - if (RemoteConfigController.isInBucket(digest, ("+121322" + String.format("%05d", random)).getBytes(), config.getName().getBytes(), config.getPercentage())) { + if (RemoteConfigController.isInBucket(digest, UUID.randomUUID(), config.getName().getBytes(), config.getPercentage(), new HashSet<>())) { count++; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsManagerTest.java index d0da47ee3..9bd87540e 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsManagerTest.java @@ -12,9 +12,12 @@ import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; import org.whispersystems.textsecuregcm.storage.RemoteConfig; import org.whispersystems.textsecuregcm.storage.RemoteConfigs; import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import java.util.HashSet; import java.util.List; +import io.dropwizard.auth.Auth; import static org.assertj.core.api.Java6Assertions.assertThat; public class RemoteConfigsManagerTest { @@ -33,9 +36,11 @@ public class RemoteConfigsManagerTest { @Test public void testUpdate() throws InterruptedException { - remoteConfigs.set(new RemoteConfig("android.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 75)); + remoteConfigs.set(new RemoteConfig("android.stickers", 50, new HashSet<>() {{ + add(AuthHelper.VALID_UUID); + }})); + remoteConfigs.set(new RemoteConfig("ios.stickers", 50, new HashSet<>())); + remoteConfigs.set(new RemoteConfig("ios.stickers", 75, new HashSet<>())); Thread.sleep(501); @@ -44,8 +49,12 @@ public class RemoteConfigsManagerTest { assertThat(results.size()).isEqualTo(2); assertThat(results.get(0).getName()).isEqualTo("android.stickers"); assertThat(results.get(0).getPercentage()).isEqualTo(50); + assertThat(results.get(0).getUuids().size()).isEqualTo(1); + assertThat(results.get(0).getUuids().contains(AuthHelper.VALID_UUID)).isTrue(); + assertThat(results.get(1).getName()).isEqualTo("ios.stickers"); assertThat(results.get(1).getPercentage()).isEqualTo(75); + assertThat(results.get(1).getUuids()).isEmpty(); } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java index d1cb4696a..b639384ac 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RemoteConfigsTest.java @@ -11,10 +11,13 @@ import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguratio import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase; import org.whispersystems.textsecuregcm.storage.RemoteConfig; import org.whispersystems.textsecuregcm.storage.RemoteConfigs; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; import java.sql.SQLException; +import java.util.HashSet; import java.util.List; +import io.dropwizard.auth.Auth; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; public class RemoteConfigsTest { @@ -31,35 +34,51 @@ public class RemoteConfigsTest { @Test public void testStore() throws SQLException { - remoteConfigs.set(new RemoteConfig("android.stickers", 50)); + remoteConfigs.set(new RemoteConfig("android.stickers", 50, new HashSet<>() {{ + add(AuthHelper.VALID_UUID); + add(AuthHelper.VALID_UUID_TWO); + }})); List configs = remoteConfigs.getAll(); assertThat(configs.size()).isEqualTo(1); assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); assertThat(configs.get(0).getPercentage()).isEqualTo(50); + assertThat(configs.get(0).getUuids().size()).isEqualTo(2); + assertThat(configs.get(0).getUuids().contains(AuthHelper.VALID_UUID)).isTrue(); + assertThat(configs.get(0).getUuids().contains(AuthHelper.VALID_UUID_TWO)).isTrue(); + assertThat(configs.get(0).getUuids().contains(AuthHelper.INVALID_UUID)).isFalse(); } @Test public void testUpdate() throws SQLException { - remoteConfigs.set(new RemoteConfig("android.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 75)); + remoteConfigs.set(new RemoteConfig("android.stickers", 50, new HashSet<>())); + + remoteConfigs.set(new RemoteConfig("ios.stickers", 50, new HashSet<>() {{ + add(AuthHelper.DISABLED_UUID); + }})); + + remoteConfigs.set(new RemoteConfig("ios.stickers", 75, new HashSet<>())); List configs = remoteConfigs.getAll(); assertThat(configs.size()).isEqualTo(2); assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); assertThat(configs.get(0).getPercentage()).isEqualTo(50); + assertThat(configs.get(0).getUuids().size()).isEqualTo(0); + assertThat(configs.get(1).getName()).isEqualTo("ios.stickers"); assertThat(configs.get(1).getPercentage()).isEqualTo(75); + assertThat(configs.get(1).getUuids().size()).isEqualTo(0); } @Test public void testDelete() { - remoteConfigs.set(new RemoteConfig("android.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 50)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 75)); + remoteConfigs.set(new RemoteConfig("android.stickers", 50, new HashSet<>() {{ + add(AuthHelper.VALID_UUID); + }})); + remoteConfigs.set(new RemoteConfig("ios.stickers", 50, new HashSet<>())); + remoteConfigs.set(new RemoteConfig("ios.stickers", 75, new HashSet<>())); remoteConfigs.delete("android.stickers"); List configs = remoteConfigs.getAll();