Migrate reserved usernames from a relational database to DynamoDB

This commit is contained in:
Jon Chambers 2021-11-16 17:30:18 -05:00 committed by Jon Chambers
parent 559205e33f
commit c910fa406d
8 changed files with 172 additions and 120 deletions

View File

@ -153,6 +153,10 @@ pendingDevicesDynamoDb: # DynamoDB table configuration
region: us-west-2
tableName: Example_PendingDevices
reservedUsernamesDynamoDb: # DynamoDB table configuration
region: us-west-2
tableName: Example_ReservedUsernames
phoneNumberIdentifiersDynamoDb: # DynamoDB table configuration
region: us-west-2
tableName: Example_PhoneNumberIdentifiers

View File

@ -206,6 +206,11 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty
private DynamoDbConfiguration pendingDevicesDynamoDb;
@Valid
@NotNull
@JsonProperty
private DynamoDbConfiguration reservedUsernamesDynamoDb;
@Valid
@NotNull
@JsonProperty
@ -551,6 +556,10 @@ public class WhisperServerConfiguration extends Configuration {
return pendingDevicesDynamoDb;
}
public DynamoDbConfiguration getReservedUsernamesDynamoDbConfiguration() {
return reservedUsernamesDynamoDb;
}
public DonationConfiguration getDonationConfiguration() {
return donation;
}

View File

@ -332,6 +332,9 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
DynamoDbClient accountsDynamoDbClient = DynamoDbFromConfig.client(config.getAccountsDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DynamoDbClient reservedUsernamesDynamoDbClient = DynamoDbFromConfig.client(config.getReservedUsernamesDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DynamoDbClient phoneNumberIdentifiersDynamoDbClient =
DynamoDbFromConfig.client(config.getPhoneNumberIdentifiersDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
@ -376,7 +379,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(phoneNumberIdentifiersDynamoDbClient,
config.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(reservedUsernamesDynamoDbClient,
config.getReservedUsernamesDynamoDbConfiguration().getTableName());
Profiles profiles = new Profiles(accountDatabase);
Keys keys = new Keys(preKeyDynamoDb, config.getKeysDynamoDbConfiguration().getTableName());
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(messageDynamoDb,

View File

@ -1,58 +1,91 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.SharedMetricRegistries;
import com.codahale.metrics.Timer;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import com.google.common.annotations.VisibleForTesting;
import org.whispersystems.textsecuregcm.util.Constants;
import java.util.Optional;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import java.util.Map;
import java.util.UUID;
import static com.codahale.metrics.MetricRegistry.name;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.AttributeValues;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
public class ReservedUsernames {
public static final String ID = "id";
public static final String UID = "uuid";
public static final String USERNAME = "username";
private final DynamoDbClient dynamoDbClient;
private final String tableName;
private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME);
private final Timer queryTimer = metricRegistry.timer(name(ReservedUsernames.class, "query"));
private final FaultTolerantDatabase database;
public ReservedUsernames(FaultTolerantDatabase database) {
this.database = database;
}
public boolean isReserved(String username, UUID uuid) {
return database.with(jdbi -> jdbi.withHandle(handle -> {
try (Timer.Context ignored = queryTimer.time()) {
Optional<Integer> reservations = handle.createQuery("SELECT COUNT(*) FROM reserved_usernames WHERE " + UID + " != :uuid AND :username ~* " + USERNAME)
.bind("username", username)
.bind("uuid", uuid)
.mapTo(Integer.class)
.findFirst();
return reservations.isPresent() && reservations.get() > 0;
}
}));
}
private final LoadingCache<String, Pattern> patternCache = CacheBuilder.newBuilder()
.maximumSize(1_000)
.build(new CacheLoader<>() {
@Override
public Pattern load(final String s) {
return Pattern.compile(s, Pattern.CASE_INSENSITIVE);
}
});
@VisibleForTesting
public void setReserved(String username, UUID reservedFor) {
database.use(jdbi -> jdbi.useHandle(handle -> {
handle.createUpdate("INSERT INTO reserved_usernames (" + USERNAME + ", " + UID + ") VALUES(:username, :uuid)")
.bind("username", username)
.bind("uuid", reservedFor)
.execute();
}));
static final String KEY_PATTERN = "P";
private static final String ATTR_RESERVED_FOR_UUID = "U";
private static final Timer IS_RESERVED_TIMER = Metrics.timer(name(ReservedUsernames.class, "isReserved"));
private static final Logger log = LoggerFactory.getLogger(ReservedUsernames.class);
public ReservedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) {
this.dynamoDbClient = dynamoDbClient;
this.tableName = tableName;
}
public boolean isReserved(final String username, final UUID accountIdentifier) {
return IS_RESERVED_TIMER.record(() -> {
final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder()
.tableName(tableName)
.build());
for (final ScanResponse scanResponse : scanIterable) {
if (scanResponse.hasItems()) {
for (final Map<String, AttributeValue> item : scanResponse.items()) {
try {
final Pattern pattern = patternCache.get(item.get(KEY_PATTERN).s());
final UUID reservedFor = AttributeValues.getUUID(item, ATTR_RESERVED_FOR_UUID, null);
if (pattern.matcher(username).matches() && !accountIdentifier.equals(reservedFor)) {
return true;
}
} catch (final Exception e) {
log.error("Failed to load pattern from item: {}", item, e);
}
}
}
}
return false;
});
}
public void reserveUsername(final String pattern, final UUID reservedFor) {
dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(tableName)
.item(Map.of(
KEY_PATTERN, AttributeValues.fromString(pattern),
ATTR_RESERVED_FOR_UUID, AttributeValues.fromUUID(reservedFor)))
.build());
}
}

View File

@ -140,6 +140,10 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
DynamoDbClient pendingAccountsDynamoDbClient = DynamoDbFromConfig.client(configuration.getPendingAccountsDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DynamoDbClient reservedUsernamesDynamoDbClient =
DynamoDbFromConfig.client(configuration.getReservedUsernamesDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
AmazonDynamoDB deletedAccountsLockDynamoDbClient = AmazonDynamoDBClientBuilder.standard()
.withRegion(configuration.getDeletedAccountsLockDynamoDbConfiguration().getRegion())
.withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout(
@ -166,7 +170,8 @@ public class DeleteUserCommand extends EnvironmentCommand<WhisperServerConfigura
configuration.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
Profiles profiles = new Profiles(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(reservedUsernamesDynamoDbClient,
configuration.getReservedUsernamesDynamoDbConfiguration().getTableName());
Keys keys = new Keys(preKeysDynamoDb,
configuration.getKeysDynamoDbConfiguration().getTableName());
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(messageDynamoDb,

View File

@ -118,6 +118,9 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
DynamoDbClient phoneNumberIdentifiersDynamoDbClient =
DynamoDbFromConfig.client(configuration.getPhoneNumberIdentifiersDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
DynamoDbClient reservedUsernamesDynamoDbClient =
DynamoDbFromConfig.client(configuration.getReservedUsernamesDynamoDbConfiguration(),
software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create());
FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster",
configuration.getCacheClusterConfiguration(), redisClusterClientResources);
@ -171,7 +174,8 @@ public class SetUserDiscoverabilityCommand extends EnvironmentCommand<WhisperSer
configuration.getPhoneNumberIdentifiersDynamoDbConfiguration().getTableName());
Usernames usernames = new Usernames(accountDatabase);
Profiles profiles = new Profiles(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(accountDatabase);
ReservedUsernames reservedUsernames = new ReservedUsernames(reservedUsernamesDynamoDbClient,
configuration.getReservedUsernamesDynamoDbConfiguration().getTableName());
Keys keys = new Keys(preKeysDynamoDb,
configuration.getKeysDynamoDbConfiguration().getTableName());
MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(messageDynamoDb,

View File

@ -0,0 +1,69 @@
/*
* Copyright 2013-2021 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.storage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
class ReservedUsernamesTest {
private static final String RESERVED_USERNAMES_TABLE_NAME = "reserved_usernames_test";
@RegisterExtension
static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
.tableName(RESERVED_USERNAMES_TABLE_NAME)
.hashKey(ReservedUsernames.KEY_PATTERN)
.attributeDefinition(AttributeDefinition.builder()
.attributeName(ReservedUsernames.KEY_PATTERN)
.attributeType(ScalarAttributeType.S)
.build())
.build();
private static final UUID RESERVED_FOR_UUID = UUID.randomUUID();
private ReservedUsernames reservedUsernames;
@BeforeEach
void setUp() {
reservedUsernames =
new ReservedUsernames(dynamoDbExtension.getDynamoDbClient(), RESERVED_USERNAMES_TABLE_NAME);
}
@ParameterizedTest
@MethodSource
void isReserved(final String username, final UUID uuid, final boolean expectReserved) {
reservedUsernames.reserveUsername(".*myusername.*", RESERVED_FOR_UUID);
reservedUsernames.reserveUsername("^foobar$", RESERVED_FOR_UUID);
assertEquals(expectReserved, reservedUsernames.isReserved(username, uuid));
}
private static Stream<Arguments> isReserved() {
return Stream.of(
Arguments.of("myusername", UUID.randomUUID(), true),
Arguments.of("myusername", RESERVED_FOR_UUID, false),
Arguments.of("thyusername", UUID.randomUUID(), false),
Arguments.of("somemyusername", UUID.randomUUID(), true),
Arguments.of("myusernamesome", UUID.randomUUID(), true),
Arguments.of("somemyusernamesome", UUID.randomUUID(), true),
Arguments.of("MYUSERNAME", UUID.randomUUID(), true),
Arguments.of("foobar", UUID.randomUUID(), true),
Arguments.of("foobar", RESERVED_FOR_UUID, false),
Arguments.of("somefoobar", UUID.randomUUID(), false),
Arguments.of("foobarsome", UUID.randomUUID(), false),
Arguments.of("somefoobarsome", UUID.randomUUID(), false),
Arguments.of("FOOBAR", UUID.randomUUID(), true));
}
}

View File

@ -1,76 +0,0 @@
/*
* Copyright 2013-2020 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.tests.storage;
import com.opentable.db.postgres.embedded.LiquibasePreparer;
import com.opentable.db.postgres.junit.EmbeddedPostgresRules;
import com.opentable.db.postgres.junit.PreparedDbRule;
import org.jdbi.v3.core.Jdbi;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.storage.FaultTolerantDatabase;
import org.whispersystems.textsecuregcm.storage.ReservedUsernames;
import org.whispersystems.textsecuregcm.storage.Usernames;
import java.io.IOException;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import static junit.framework.TestCase.assertTrue;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.Assert.assertFalse;
public class ReservedUsernamesTest {
@Rule
public PreparedDbRule db = EmbeddedPostgresRules.preparedDatabase(LiquibasePreparer.forClasspathLocation("accountsdb.xml"));
private ReservedUsernames reserved;
@Before
public void setupAccountsDao() {
FaultTolerantDatabase faultTolerantDatabase = new FaultTolerantDatabase("reservedUsernamesTest",
Jdbi.create(db.getTestDatabase()),
new CircuitBreakerConfiguration());
this.reserved = new ReservedUsernames(faultTolerantDatabase);
}
@Test
public void testReservedRegexp() {
UUID reservedFor = UUID.randomUUID();
String username = ".*myusername.*";
reserved.setReserved(username, reservedFor);
assertTrue(reserved.isReserved("myusername", UUID.randomUUID()));
assertFalse(reserved.isReserved("myusername", reservedFor));
assertFalse(reserved.isReserved("thyusername", UUID.randomUUID()));
assertTrue(reserved.isReserved("somemyusername", UUID.randomUUID()));
assertTrue(reserved.isReserved("myusernamesome", UUID.randomUUID()));
assertTrue(reserved.isReserved("somemyusernamesome", UUID.randomUUID()));
}
@Test
public void testReservedLiteral() {
UUID reservedFor = UUID.randomUUID();
String username = "^foobar$";
reserved.setReserved(username, reservedFor);
assertTrue(reserved.isReserved("foobar", UUID.randomUUID()));
assertFalse(reserved.isReserved("foobar", reservedFor));
assertFalse(reserved.isReserved("somefoobar", UUID.randomUUID()));
assertFalse(reserved.isReserved("foobarsome", UUID.randomUUID()));
assertFalse(reserved.isReserved("somefoobarsome", UUID.randomUUID()));
}
}