diff --git a/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/DynamoDbEncryptionEnhancedClientNullIntegrationTest.java b/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/DynamoDbEncryptionEnhancedClientNullIntegrationTest.java new file mode 100644 index 000000000..478911926 --- /dev/null +++ b/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/DynamoDbEncryptionEnhancedClientNullIntegrationTest.java @@ -0,0 +1,167 @@ +package software.amazon.cryptography.dbencryptionsdk.dynamodb.enhancedclient; + +import org.testng.annotations.Test; + +import java.time.Clock; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.extensions.AutoGeneratedTimestampRecordExtension; +import software.amazon.awssdk.enhanced.dynamodb.internal.converter.attribute.InstantAsStringAttributeConverter; +import software.amazon.awssdk.enhanced.dynamodb.model.GetItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +import software.amazon.cryptography.dbencryptionsdk.dynamodb.DynamoDbEncryptionInterceptor; +import software.amazon.cryptography.dbencryptionsdk.dynamodb.enhancedclient.validdatamodels.SimpleViaLombok; +import software.amazon.cryptography.materialproviders.IKeyring; + +import static java.lang.Math.abs; +import static software.amazon.cryptography.dbencryptionsdk.dynamodb.TestUtils.TEST_TABLE_NAME; +import static software.amazon.cryptography.dbencryptionsdk.dynamodb.TestUtils.createKmsKeyring; + +/** + * From the DynamoDB Enhanced Client's Developer Guide: + * > When you use the putItem API, + * > the enhanced client does not include null-valued attributes of + * > a mapped data object in the request to DynamoDB. + * This test asserts the Enhanced Client's Null behavior does not cause an issue. + * It SHOULD NOT. + * The DB-ESDK for DynamoDB prevents Signed Attributes + * from being used in an update expression. + * As such, all writes with a Signed Attribute are only permitted + * with a full write. + * Even though the Enhanced Client does not include the null values + * in the request to DynamoDB, + * since the request is made with a full WRITE, + * the pre-existing NOW null values will be dropped. + */ +public class DynamoDbEncryptionEnhancedClientNullIntegrationTest { + final static String PARTITION = "TestEnhancedClientPutNull"; + final static int SORT = 20230716; + final static String COULD_NOT_READ_MSG = String.format( + "Testing Null Write with Enhanced Client Required a particular Read that could not complete. " + + "Recommend Deleting the record via the console and retrying. " + + "Record's Key is Partition: %s, Sort: %s", PARTITION, SORT); + public static final int TOLERANCE = 1000; + + @Test + public void TestEnhancedClientPutNull() { + final String UNSIGNED_PREFIX = ":"; + final IKeyring kmsKeyring = createKmsKeyring(); + final TableSchema schemaOnEncrypt = + TableSchema.fromImmutableClass(SimpleViaLombok.class); + final InstantAsStringAttributeConverter instantConverter = + InstantAsStringAttributeConverter.create(); + final Key key = Key.builder() + .partitionValue(PARTITION).sortValue(SORT) + .build(); + final Clock clock = Clock.systemUTC(); + + DynamoDbClient ddb = initDDBWithEncryption(UNSIGNED_PREFIX, kmsKeyring, schemaOnEncrypt); + DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .extensions(AutoGeneratedTimestampRecordExtension.create()) + .dynamoDbClient(ddb) + .build(); + DynamoDbTable table = enhancedClient.table(TEST_TABLE_NAME, schemaOnEncrypt); + + final SimpleViaLombok.SimpleViaLombokBuilder itemBuilder = SimpleViaLombok.builder(); + itemBuilder.partitionKey(PARTITION).sortKey(SORT); + itemBuilder.attribute1("In the Jungle"); + itemBuilder.attribute2("The mighty Jungle"); + itemBuilder.attribute3("The Lion Sleeps Tonight"); + SimpleViaLombok item = itemBuilder.build(); + Instant firstWriteTime = clock.instant(); + table.putItem(item); + + // Get the item back from the table + SimpleViaLombok firstWriteDecrypted = getItemWithTimeStamp( + firstWriteTime, table, key, TOLERANCE); + + // We are going to Modify the Values to be Null, + // Write that to DDB with Encryption + final SimpleViaLombok.SimpleViaLombokBuilder nullBuilder = SimpleViaLombok.builder(); + nullBuilder.partitionKey(PARTITION).sortKey(SORT); + nullBuilder.attribute1(null).attribute2(null).attribute3(null); + SimpleViaLombok nullItem = nullBuilder.build(); + final Instant secondWriteTime = clock.instant(); + // We need to be sure that we are replacing This Test Runs' Write, + // so we will use a condition expression + table.putItem( + (PutItemEnhancedRequest.Builder requestBuilder) -> + requestBuilder.conditionExpression( + Expression.builder().expression("#a = :b") + .putExpressionName("#a", ":lastModifiedTimeStamp") + .putExpressionValue(":b", + instantConverter.transformFrom(firstWriteDecrypted.getLastModifiedTimeStamp())) + .build()) + .item(nullItem) + ); + + SimpleViaLombok secondWriteDecrypted = getItemWithTimeStamp(secondWriteTime, table, key, TOLERANCE); + assert Objects.isNull(secondWriteDecrypted.getAttribute1()); + assert Objects.isNull(secondWriteDecrypted.getAttribute2()); + assert Objects.isNull(secondWriteDecrypted.getAttribute3()); + } + + private static SimpleViaLombok getItemWithTimeStamp( + final Instant writeTime, + final DynamoDbTable table, + final Key key, + final int tolerance + ) { + int retries = 3; + final GetItemEnhancedRequest request = + GetItemEnhancedRequest.builder() + .consistentRead(true).key(key).build(); + try { + while (retries != 0) { + SimpleViaLombok read = table.getItem(request); + Instant readTime = read.getLastModifiedTimeStamp(); + long diff = writeTime.toEpochMilli() - readTime.toEpochMilli(); + if (abs(diff) < tolerance) return read; + // We did not get the record we want, sleep for 1 Seconds + Thread.sleep(TOLERANCE); + retries = retries - 1; + } + } catch (InterruptedException ex) { + throw new RuntimeException(COULD_NOT_READ_MSG, ex); + } + throw new RuntimeException(COULD_NOT_READ_MSG); + } + + private static DynamoDbClient initDDBWithEncryption( + final String unsignedPrefix, + final IKeyring kmsKeyring, + TableSchema schemaOnEncrypt + ) { + Map tableConfigs = new HashMap<>(); + tableConfigs.put(TEST_TABLE_NAME, + DynamoDbEnhancedTableEncryptionConfig.builder() + .logicalTableName(TEST_TABLE_NAME) + .keyring(kmsKeyring) + .allowedUnsignedAttributePrefix(unsignedPrefix) + .schemaOnEncrypt(schemaOnEncrypt) + .build()); + DynamoDbEncryptionInterceptor interceptor = + DynamoDbEnhancedClientEncryption.CreateDynamoDbEncryptionInterceptor( + CreateDynamoDbEncryptionInterceptorInput.builder() + .tableEncryptionConfigs(tableConfigs) + .build() + ); + return DynamoDbClient.builder() + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .addExecutionInterceptor(interceptor) + .build()) + .build(); + } +} diff --git a/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/validdatamodels/SimpleViaLombok.java b/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/validdatamodels/SimpleViaLombok.java new file mode 100644 index 000000000..41a69f435 --- /dev/null +++ b/DynamoDbEncryption/runtimes/java/src/test/java/software/amazon/cryptography/dbencryptionsdk/dynamodb/enhancedclient/validdatamodels/SimpleViaLombok.java @@ -0,0 +1,37 @@ +package software.amazon.cryptography.dbencryptionsdk.dynamodb.enhancedclient.validdatamodels; + +import java.time.Instant; + +import lombok.Builder; +import lombok.Getter; +import lombok.Value; + +import software.amazon.awssdk.enhanced.dynamodb.extensions.annotations.DynamoDbAutoGeneratedTimestampAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbImmutable; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +import software.amazon.cryptography.dbencryptionsdk.dynamodb.enhancedclient.DynamoDbEncryptionDoNothing; +import software.amazon.cryptography.dbencryptionsdk.dynamodb.enhancedclient.DynamoDbEncryptionSignOnly; + +@Value +@Builder +@DynamoDbImmutable(builder = SimpleViaLombok.SimpleViaLombokBuilder.class) +public class SimpleViaLombok { + @Getter(onMethod_= {@DynamoDbPartitionKey, @DynamoDbAttribute(value = "partition_key")}) + String partitionKey; + @Getter(onMethod_= {@DynamoDbSortKey, @DynamoDbAttribute(value = "sort_key")}) + int sortKey; + String attribute1; + @Getter(onMethod_=@DynamoDbEncryptionSignOnly) + String attribute2; + @Getter(onMethod_= {@DynamoDbEncryptionDoNothing, @DynamoDbAttribute(value = ":attribute3")}) + String attribute3; + @Getter(onMethod_= { + @DynamoDbEncryptionDoNothing, + @DynamoDbAttribute(value = ":lastModifiedTimeStamp"), + @DynamoDbAutoGeneratedTimestampAttribute + }) + Instant lastModifiedTimeStamp; +}