diff --git a/DynamoDbEncryption/runtimes/python/src/aws_dbesdk_dynamodb/encrypted/client.py b/DynamoDbEncryption/runtimes/python/src/aws_dbesdk_dynamodb/encrypted/client.py new file mode 100644 index 000000000..53007a350 --- /dev/null +++ b/DynamoDbEncryption/runtimes/python/src/aws_dbesdk_dynamodb/encrypted/client.py @@ -0,0 +1,647 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""High-level helper class to provide an encrypting wrapper for boto3 DynamoDB clients.""" +from copy import deepcopy +from typing import Any + +import botocore.client + +from aws_dbesdk_dynamodb.encrypted.boto3_interface import EncryptedBotoInterface +from aws_dbesdk_dynamodb.encrypted.paginator import EncryptedPaginator +from aws_dbesdk_dynamodb.internal.client_to_resource import ClientShapeToResourceShapeConverter +from aws_dbesdk_dynamodb.internal.resource_to_client import ResourceShapeToClientShapeConverter +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.models import ( + DynamoDbTablesEncryptionConfig, +) +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb_transforms.client import ( + DynamoDbEncryptionTransforms, +) +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb_transforms.models import ( + BatchExecuteStatementInputTransformInput, + BatchExecuteStatementOutputTransformInput, + BatchGetItemInputTransformInput, + BatchGetItemOutputTransformInput, + BatchWriteItemInputTransformInput, + BatchWriteItemOutputTransformInput, + DeleteItemInputTransformInput, + DeleteItemOutputTransformInput, + ExecuteStatementInputTransformInput, + ExecuteStatementOutputTransformInput, + ExecuteTransactionInputTransformInput, + ExecuteTransactionOutputTransformInput, + GetItemInputTransformInput, + GetItemOutputTransformInput, + PutItemInputTransformInput, + PutItemOutputTransformInput, + QueryInputTransformInput, + QueryOutputTransformInput, + ScanInputTransformInput, + ScanOutputTransformInput, + TransactGetItemsInputTransformInput, + TransactGetItemsOutputTransformInput, + TransactWriteItemsInputTransformInput, + TransactWriteItemsOutputTransformInput, + UpdateItemInputTransformInput, + UpdateItemOutputTransformInput, +) + + +class EncryptedClient(EncryptedBotoInterface): + """ + Wrapper for a boto3 DynamoDB client that transparently encrypts/decrypts items. + + This class implements the complete boto3 DynamoDB client API, allowing it to serve as a + drop-in replacement that transparently handles encryption and decryption of items. + + The API matches the standard boto3 DynamoDB client interface: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#client + + This class will encrypt/decrypt items for the following operations: + + * ``put_item`` + * ``get_item`` + * ``query`` + * ``scan`` + * ``batch_write_item`` + * ``batch_get_item`` + * ``transact_get_items`` + * ``transact_write_items`` + * ``delete_item`` + + Any calls to ``update_item`` can only update unsigned attributes. If an attribute to be updated is marked as signed, + this operation will raise a ``DynamoDbEncryptionTransformsException``. + + The following operations are not supported for encrypted tables: + + * ``execute_statement`` + * ``execute_transaction`` + * ``batch_execute_statement`` + + Calling these operations for encrypted tables will raise a ``DynamoDbEncryptionTransformsException``. + This client can still be used to call these operations on plaintext tables. + + Any other operations on this class will defer to the underlying boto3 DynamoDB client's implementation. + + ``EncryptedClient`` can also return an ``EncryptedPaginator`` for transparent decryption of paginated results. + """ + + _client: botocore.client.BaseClient + _encryption_config: DynamoDbTablesEncryptionConfig + _transformer: DynamoDbEncryptionTransforms + _expect_standard_dictionaries: bool + + def __init__( + self, + *, + client: botocore.client.BaseClient, + encryption_config: DynamoDbTablesEncryptionConfig, + expect_standard_dictionaries: bool | None = False, + ): + """ + Create an ``EncryptedClient`` object. + + Args: + client (botocore.client.BaseClient): Initialized boto3 DynamoDB client + encryption_config (DynamoDbTablesEncryptionConfig): Initialized DynamoDbTablesEncryptionConfig + expect_standard_dictionaries (Optional[bool]): Does the underlying boto3 client expect items + to be standard Python dictionaries? This should only be set to True if you are using a + client obtained from a service resource or table resource (ex: ``table.meta.client``). + If this is True, ``EncryptedClient`` will expect item-like shapes to be + standard Python dictionaries (default: False). + + """ + self._client = client + self._encryption_config = encryption_config + self._transformer = DynamoDbEncryptionTransforms(config=encryption_config) + self._expect_standard_dictionaries = expect_standard_dictionaries + self._resource_to_client_shape_converter = ResourceShapeToClientShapeConverter() + self._client_to_resource_shape_converter = ClientShapeToResourceShapeConverter(delete_table_name=False) + + def put_item(self, **kwargs) -> dict[str, Any]: + """ + Put a single item to a table. Encrypts the item before writing to DynamoDB. + + The input and output syntaxes match those for the boto3 DynamoDB ``put_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/put_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``put_item`` request + syntax. The value in ``"Item"`` will be encrypted locally before being written to DynamoDB. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``put_item`` response syntax. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.put_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.put_item_request, + input_transform_method=self._transformer.put_item_input_transform, + input_transform_shape=PutItemInputTransformInput, + output_transform_method=self._transformer.put_item_output_transform, + output_transform_shape=PutItemOutputTransformInput, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.put_item_response, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.put_item_response, + client_method=self._client.put_item, + ) + + def get_item(self, **kwargs) -> dict[str, Any]: + """ + Get a single item from a table. Decrypts the item after reading from DynamoDB. + + The input and output syntaxes match those for the boto3 DynamoDB ``get_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/get_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``get_item`` request + syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``get_item`` response syntax. + The value in ``"Item"`` field be decrypted locally after being read from DynamoDB. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.get_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.get_item_request, + input_transform_method=self._transformer.get_item_input_transform, + input_transform_shape=GetItemInputTransformInput, + output_transform_method=self._transformer.get_item_output_transform, + output_transform_shape=GetItemOutputTransformInput, + client_method=self._client.get_item, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.get_item_response, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.get_item_response, + ) + + def query(self, **kwargs) -> dict[str, Any]: + """ + Query items from a table or index. Decrypts any returned items. + + The input and output syntaxes match those for the boto3 DynamoDB client ``query`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html + + Args: + **kwargs: Keyword arguments to pass to the operation. These match the boto3 query API parameters. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``query`` API response. + The ``"Items"`` field will be decrypted locally after being read from DynamoDB. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.query_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.query_request, + input_transform_method=self._transformer.query_input_transform, + input_transform_shape=QueryInputTransformInput, + output_transform_method=self._transformer.query_output_transform, + output_transform_shape=QueryOutputTransformInput, + client_method=self._client.query, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.query_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.query_response, + ) + + def scan(self, **kwargs) -> dict[str, Any]: + """ + Scan an entire table or index. Decrypts any returned items. + + The input and output syntaxes match those for the boto3 DynamoDB ``scan`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/scan.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``scan`` request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``scan`` response syntax. + The values in ``"Items"`` will be decrypted locally after being read from DynamoDB. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.scan_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.scan_request, + input_transform_method=self._transformer.scan_input_transform, + input_transform_shape=ScanInputTransformInput, + output_transform_method=self._transformer.scan_output_transform, + output_transform_shape=ScanOutputTransformInput, + client_method=self._client.scan, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.scan_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.scan_response, + ) + + def batch_write_item(self, **kwargs) -> dict[str, Any]: + """ + Put or delete multiple items in one or more tables. + + For put operations, encrypts items before writing. + + The input and output syntaxes match those for the boto3 DynamoDB ``batch_write_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/batch_write_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``batch_write_item`` + request syntax. Items in ``"PutRequest"`` values in the ``"RequestItems"`` argument will be encrypted + locally before being written to DynamoDB. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``batch_write_item`` response syntax. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_write_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_write_item_request, + input_transform_method=self._transformer.batch_write_item_input_transform, + input_transform_shape=BatchWriteItemInputTransformInput, + output_transform_method=self._transformer.batch_write_item_output_transform, + output_transform_shape=BatchWriteItemOutputTransformInput, + client_method=self._client.batch_write_item, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_write_item_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_write_item_response, + ) + + def batch_get_item(self, **kwargs) -> dict[str, Any]: + """ + Get multiple items from one or more tables. Decrypts any returned items. + + The input and output syntaxes match those for the boto3 DynamoDB ``batch_get_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/batch_get_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 ``batch_get_item`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``batch_get_item`` response syntax. + The values in ``"Responses"`` will be decrypted locally after being read from DynamoDB. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_get_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_get_item_request, + input_transform_method=self._transformer.batch_get_item_input_transform, + input_transform_shape=BatchGetItemInputTransformInput, + output_transform_method=self._transformer.batch_get_item_output_transform, + output_transform_shape=BatchGetItemOutputTransformInput, + client_method=self._client.batch_get_item, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_get_item_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_get_item_response, + ) + + def transact_get_items(self, **kwargs) -> dict[str, Any]: + """ + Get multiple items in a single transaction. Decrypts any returned items. + + The input and output syntaxes match those for the boto3 DynamoDB ``transact_get_items`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/transact_get_items.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``transact_get_items`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``transact_get_items`` response syntax. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.transact_get_items_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.transact_get_items_request, + input_transform_method=self._transformer.transact_get_items_input_transform, + input_transform_shape=TransactGetItemsInputTransformInput, + output_transform_method=self._transformer.transact_get_items_output_transform, + output_transform_shape=TransactGetItemsOutputTransformInput, + client_method=self._client.transact_get_items, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.transact_get_items_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.transact_get_items_response, + ) + + def transact_write_items(self, **kwargs) -> dict[str, Any]: + """ + Perform multiple write operations in a single transaction. + + For put operations, encrypts items before writing. + + The input and output syntaxes match those for the boto3 DynamoDB client ``transact_write_items`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/transact_write_items.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``transact_write_items`` + request syntax. Any ``"PutRequest"`` values in the ``"TransactItems"`` argument will be encrypted + locally before being written to DynamoDB. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``transact_write_items`` response syntax. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.transact_write_items_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.transact_write_items_request, + input_transform_method=self._transformer.transact_write_items_input_transform, + input_transform_shape=TransactWriteItemsInputTransformInput, + output_transform_method=self._transformer.transact_write_items_output_transform, + output_transform_shape=TransactWriteItemsOutputTransformInput, + client_method=self._client.transact_write_items, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.transact_write_items_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.transact_write_items_response, + ) + + def delete_item(self, **kwargs): + """ + Delete an item from a table by the specified key. + + The input and output syntaxes match those for the boto3 DynamoDB client ``delete_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/delete_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``delete_item`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``delete_item`` response syntax. + Any values in the ``"Attributes"`` field will be decrypted locally after being read from DynamoDB. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.delete_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.delete_item_request, + input_transform_method=self._transformer.delete_item_input_transform, + input_transform_shape=DeleteItemInputTransformInput, + output_transform_method=self._transformer.delete_item_output_transform, + output_transform_shape=DeleteItemOutputTransformInput, + client_method=self._client.delete_item, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.delete_item_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.delete_item_response, + ) + + def update_item(self, **kwargs): + """ + Update an unsigned attribute in an item on a table. + + If the attribute is signed, this operation will raise DynamoDbEncryptionTransformsException. + + The input and output syntaxes match those for the boto3 DynamoDB client ``update_item`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/update_item.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``update_item`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``update_item`` response syntax. + + Raises: + DynamoDbEncryptionTransformsException: If an attribute specified in the ``UpdateExpression`` is signed. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.update_item_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.update_item_request, + input_transform_method=self._transformer.update_item_input_transform, + input_transform_shape=UpdateItemInputTransformInput, + output_transform_method=self._transformer.update_item_output_transform, + output_transform_shape=UpdateItemOutputTransformInput, + client_method=self._client.update_item, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.update_item_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.update_item_response, + ) + + def execute_statement(self, **kwargs): + """ + Call ``execute_statement`` on the underlying client if the table is not configured for encryption. + + If the table is configured for encryption, this operation will raise DynamoDbEncryptionTransformsException. + + The input and output syntaxes match those for the boto3 DynamoDB client ``execute_statement`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/execute_statement.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``execute_statement`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``execute_statement`` response syntax. + + Raises: + DynamoDbEncryptionTransformsException: If this operation is called for an encrypted table. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.execute_statement_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.execute_statement_request, + input_transform_method=self._transformer.execute_statement_input_transform, + input_transform_shape=ExecuteStatementInputTransformInput, + output_transform_method=self._transformer.execute_statement_output_transform, + output_transform_shape=ExecuteStatementOutputTransformInput, + client_method=self._client.execute_statement, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.execute_statement_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.execute_statement_response, + ) + + def execute_transaction(self, **kwargs): + """ + Call ``execute_transaction`` on the underlying client if the table is not configured for encryption. + + If the table is configured for encryption, this operation will raise DynamoDbEncryptionTransformsException. + + The input and output syntaxes match those for the boto3 DynamoDB client ``execute_transaction`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/execute_transaction.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client ``execute_transaction`` + request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``execute_transaction`` response syntax. + + Raises: + DynamoDbEncryptionTransformsException: If this operation is called for an encrypted table. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.execute_transaction_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.execute_transaction_request, + input_transform_method=self._transformer.execute_transaction_input_transform, + input_transform_shape=ExecuteTransactionInputTransformInput, + output_transform_method=self._transformer.execute_transaction_output_transform, + output_transform_shape=ExecuteTransactionOutputTransformInput, + client_method=self._client.execute_transaction, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.execute_transaction_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.execute_transaction_response, + ) + + def batch_execute_statement(self, **kwargs): + """ + Call ``batch_execute_statement`` on the underlying client if the table is not configured for encryption. + + If the table is configured for encryption, this operation will raise DynamoDbEncryptionTransformsException. + + The input and output syntaxes match those for the boto3 DynamoDB client ``batch_execute_statement`` API: + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/batch_execute_statement.html + + Args: + **kwargs: Keyword arguments to pass to the operation. This matches the boto3 client + ``batch_execute_statement`` request syntax. + + Returns: + dict: The response from DynamoDB. This matches the boto3 client ``batch_execute_statement`` response syntax. + + Raises: + DynamoDbEncryptionTransformsException: If this operation is called for an encrypted table. + + """ + return self._client_operation_logic( + operation_input=kwargs, + input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_execute_statement_request, + input_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_execute_statement_request, + input_transform_method=self._transformer.batch_execute_statement_input_transform, + input_transform_shape=BatchExecuteStatementInputTransformInput, + output_transform_method=self._transformer.batch_execute_statement_output_transform, + output_transform_shape=BatchExecuteStatementOutputTransformInput, + client_method=self._client.batch_execute_statement, + output_item_to_dict_transform_method=self._client_to_resource_shape_converter.batch_execute_statement_response, + output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.batch_execute_statement_response, + ) + + def get_paginator(self, operation_name: str) -> EncryptedPaginator | botocore.client.Paginator: + """ + Get a paginator from the underlying client. + + If the paginator requested is for "scan" or "query", the paginator returned will + transparently decrypt the returned items. + + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#paginators + + Args: + operation_name (str): Name of operation for which to get paginator + + Returns: + EncryptedPaginator | botocore.client.Paginator: An EncryptedPaginator that will transparently decrypt items + for ``scan``/``query`` operations; for other operations, the standard paginator. + + """ + paginator = self._client.get_paginator(operation_name) + + if operation_name in ("scan", "query"): + return EncryptedPaginator( + paginator=paginator, + encryption_config=self._encryption_config, + expect_standard_dictionaries=self._expect_standard_dictionaries, + ) + else: + # The paginator can still be used for list_backups, list_tables, and list_tags_of_resource, + # but there is nothing to encrypt/decrypt in these operations. + return paginator + + def _client_operation_logic( + self, + *, + operation_input: dict[str, Any], + input_item_to_ddb_transform_method: callable, + input_item_to_dict_transform_method: callable, + input_transform_method: Any, + input_transform_shape: Any, + output_transform_method: Any, + output_transform_shape: Any, + client_method: Any, + output_item_to_ddb_transform_method: callable, + output_item_to_dict_transform_method: callable, + ) -> dict[str, Any]: + """ + Shared logic to interface between boto3 Client operation inputs and encryption transformers. + + This captures the shared pattern to call encryption/decryption transformer code + and boto3 Clients across all methods in this class. + + Args: + operation_input: The input to the operation + input_item_to_ddb_transform_method: Method to transform input items from standard dictionaries + to DynamoDB JSON + input_item_to_dict_transform_method: Method to transform input items from DynamoDB JSON + to standard dictionaries + input_transform_method: The method to transform the input for encryption + input_transform_shape: The shape of the input transform + output_transform_method: The method to transform the output for decryption + output_transform_shape: The shape of the output transform + client_method: The underlying client method to call + output_item_to_ddb_transform_method: Method to transform output items from standard dictionaries + to DynamoDB JSON + output_item_to_dict_transform_method: Method to transform output items from DynamoDB JSON + to standard dictionaries + + Returns: + dict: The transformed response from DynamoDB + + """ + # If _expect_standard_dictionaries is true, input items are expected to be standard dictionaries, + # and need to be converted to DDB-JSON before encryption. + sdk_input = deepcopy(operation_input) + if self._expect_standard_dictionaries: + if "TableName" in sdk_input: + self._resource_to_client_shape_converter.table_name = sdk_input["TableName"] + sdk_input = input_item_to_ddb_transform_method(sdk_input) + + # Apply DBESDK transformation to the input + transformed_request = input_transform_method(input_transform_shape(sdk_input=sdk_input)).transformed_input + + # If _expect_standard_dictionaries is true, the boto3 client expects items to be standard dictionaries, + # and need to be converted from DDB-JSON to a standard dictionary before being passed to the boto3 client. + if self._expect_standard_dictionaries: + transformed_request = input_item_to_dict_transform_method(transformed_request) + + sdk_response = client_method(**transformed_request) + + # If _expect_standard_dictionaries is true, the boto3 client returns items as standard dictionaries, + # and needs to convert the standard dictionary to DDB-JSON before passing the response to the DBESDK. + if self._expect_standard_dictionaries: + sdk_response = output_item_to_ddb_transform_method(sdk_response) + + # Apply DBESDK transformation to the boto3 output + dbesdk_response = output_transform_method( + output_transform_shape( + original_input=sdk_input, + sdk_output=sdk_response, + ) + ).transformed_output + + # Copy any missing fields from the SDK output to the response (e.g. ConsumedCapacity) + dbesdk_response = self._copy_sdk_response_to_dbesdk_response(sdk_response, dbesdk_response) + + # If _expect_standard_dictionaries is true, output items are expected to be standard dictionaries, + # and need to be converted from DDB-JSON to a standard dictionary before returning the response. + if self._expect_standard_dictionaries: + dbesdk_response = output_item_to_dict_transform_method(dbesdk_response) + + return dbesdk_response + + @property + def _boto_client_attr_name(self) -> str: + """ + Name of the attribute containing the underlying boto3 client. + + Returns: + str: '_client' + + """ + return "_client" diff --git a/DynamoDbEncryption/runtimes/python/test/integ/encrypted/README.md b/DynamoDbEncryption/runtimes/python/test/integ/encrypted/README.md new file mode 100644 index 000000000..f6a9abf10 --- /dev/null +++ b/DynamoDbEncryption/runtimes/python/test/integ/encrypted/README.md @@ -0,0 +1,10 @@ +Integration tests for encrypted interfaces. + +These integration tests verify that encrypted boto3 interfaces behave as drop-in replacements for plaintext boto3 interfaces. + +Each test runs with both a plaintext client and an encrypted client, using the same request parameters and expecting the same response. + +This validates that encrypted clients expect the same input shapes as plaintext clients +and encrypted clients return the same output shapes as plaintext clients. + +This guarantees that users can substitute encrypted interfaces without modifying their application logic. diff --git a/DynamoDbEncryption/runtimes/python/test/integ/encrypted/test_client.py b/DynamoDbEncryption/runtimes/python/test/integ/encrypted/test_client.py new file mode 100644 index 000000000..032d57c6d --- /dev/null +++ b/DynamoDbEncryption/runtimes/python/test/integ/encrypted/test_client.py @@ -0,0 +1,592 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import boto3 +import pytest + +from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient +from aws_dbesdk_dynamodb.encrypted.paginator import EncryptedPaginator +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb_transforms.errors import ( + DynamoDbEncryptionTransformsException, +) + +from ...constants import ( + INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME, + INTEG_TEST_DEFAULT_TABLE_CONFIGS, +) +from ...items import ( + complex_item_ddb, + complex_item_dict, + complex_key_ddb, + complex_key_dict, + simple_item_ddb, + simple_item_dict, + simple_key_ddb, + simple_key_dict, +) +from ...requests import ( + basic_batch_execute_statement_request_encrypted_table, + basic_batch_execute_statement_request_plaintext_table, + basic_batch_get_item_request_ddb, + basic_batch_get_item_request_dict, + basic_batch_write_item_delete_request_ddb, + basic_batch_write_item_delete_request_dict, + basic_batch_write_item_put_request_ddb, + basic_batch_write_item_put_request_dict, + basic_delete_item_request_ddb, + basic_delete_item_request_dict, + basic_execute_statement_request_encrypted_table, + basic_execute_statement_request_plaintext_table, + basic_execute_transaction_request_encrypted_table, + basic_execute_transaction_request_plaintext_table, + basic_get_item_request_ddb, + basic_get_item_request_dict, + basic_put_item_request_ddb, + basic_put_item_request_dict, + basic_query_request_ddb, + basic_query_request_dict, + basic_scan_request_ddb, + basic_scan_request_dict, + basic_transact_get_item_request_ddb, + basic_transact_get_item_request_dict, + basic_transact_write_item_delete_request_ddb, + basic_transact_write_item_delete_request_dict, + basic_transact_write_item_put_request_ddb, + basic_transact_write_item_put_request_dict, + basic_update_item_request_ddb_signed_attribute, + basic_update_item_request_ddb_unsigned_attribute, + basic_update_item_request_dict_signed_attribute, + basic_update_item_request_dict_unsigned_attribute, +) +from . import sort_dynamodb_json_lists + + +# Creates a matrix of tests for each value in param, +# with a user-friendly string for test output: +# expect_standard_dictionaries = True -> "standard_dicts" +# expect_standard_dictionaries = False -> "ddb_json" +@pytest.fixture(params=[True, False], ids=["standard_dicts", "ddb_json"]) +def expect_standard_dictionaries(request): + return request.param + + +def encrypted_client(expect_standard_dictionaries): + return EncryptedClient( + client=plaintext_client(expect_standard_dictionaries), + encryption_config=INTEG_TEST_DEFAULT_TABLE_CONFIGS, + expect_standard_dictionaries=expect_standard_dictionaries, + ) + + +def plaintext_client(expect_standard_dictionaries): + if expect_standard_dictionaries: + client = boto3.resource("dynamodb").Table(INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME).meta.client + else: + client = boto3.client("dynamodb") + return client + + +# Creates a matrix of tests for each value in param, +# with a user-friendly string for test output: +# encrypted = True -> "encrypted" +# encrypted = False -> "plaintext" +@pytest.fixture(params=[True, False], ids=["encrypted", "plaintext"]) +def encrypted(request): + return request.param + + +@pytest.fixture +def client(encrypted, expect_standard_dictionaries): + if encrypted: + return encrypted_client(expect_standard_dictionaries) + else: + return plaintext_client(expect_standard_dictionaries) + + +# Creates a matrix of tests for each value in param, +# with a user-friendly string for test output: +# use_complex_item = True -> "complex_item" +# use_complex_item = False -> "simple_item" +@pytest.fixture(params=[True, False], ids=["complex_item", "simple_item"]) +def use_complex_item(request): + return request.param + + +@pytest.fixture +def test_item(expect_standard_dictionaries, use_complex_item): + """Get a single test item in the appropriate format for the client.""" + if expect_standard_dictionaries: + if use_complex_item: + return complex_item_dict + return simple_item_dict + if use_complex_item: + return complex_item_ddb + return simple_item_ddb + + +@pytest.fixture +def test_key(expect_standard_dictionaries, use_complex_item): + """Get a single test item in the appropriate format for the client.""" + if expect_standard_dictionaries: + if use_complex_item: + return complex_key_dict + return simple_key_dict + if use_complex_item: + return complex_key_ddb + return simple_key_ddb + + +@pytest.fixture +def multiple_test_items(expect_standard_dictionaries): + """Get two test items in the appropriate format for the client.""" + if expect_standard_dictionaries: + return [simple_item_dict, complex_item_dict] + return [simple_item_ddb, complex_item_ddb] + + +@pytest.fixture +def multiple_test_keys(expect_standard_dictionaries): + """Get two test keys in the appropriate format for the client.""" + if expect_standard_dictionaries: + return [simple_key_dict, complex_key_dict] + return [simple_key_ddb, complex_key_ddb] + + +@pytest.fixture +def put_item_request(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + # Client requests with `expect_standard_dictionaries=True` use dict-formatted requests + # with an added "TableName" key. + return {**basic_put_item_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME} + return basic_put_item_request_ddb(test_item) + + +@pytest.fixture +def get_item_request(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + # Client requests with `expect_standard_dictionaries=True` use dict-formatted requests + # with an added "TableName" key. + return {**basic_get_item_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME} + return basic_get_item_request_ddb(test_item) + + +@pytest.fixture +def delete_item_request(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + return {**basic_delete_item_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME} + return basic_delete_item_request_ddb(test_item) + + +def test_GIVEN_valid_put_and_get_and_delete_requests_WHEN_put_and_get_and_delete_THEN_round_trip_passes( + client, put_item_request, get_item_request, delete_item_request +): + # Given: Valid put_item request + # When: put_item + put_response = client.put_item(**put_item_request) + # Then: put_item succeeds + assert put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid get_item request for the same item + # When: get_item + get_response = client.get_item(**get_item_request) + # Then: Resposne is equal to the original item + assert get_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + # DynamoDB JSON uses lists to represent sets, so strict equality can fail. + # Sort lists to ensure consistent ordering when comparing expected and actual items. + expected_item = sort_dynamodb_json_lists(put_item_request["Item"]) + actual_item = sort_dynamodb_json_lists(get_response["Item"]) + assert expected_item == actual_item + + # Given: Valid delete_item request for the same item + # When: delete_item + delete_response = client.delete_item(**{**delete_item_request, "ReturnValues": "ALL_OLD"}) + # Then: delete_item succeeds and contains the expected response + assert delete_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + # DynamoDB JSON uses lists to represent sets, so strict equality can fail. + # Sort lists to ensure consistent ordering when comparing expected and actual items. + expected_item = sort_dynamodb_json_lists(put_item_request["Item"]) + actual_item = sort_dynamodb_json_lists(delete_response["Attributes"]) + assert expected_item == actual_item + + # Given: Valid get_item request for the same item + # When: get_item + get_response = client.get_item(**get_item_request) + # Then: get_item is empty (i.e. the item was deleted) + assert "Item" not in get_response + + +@pytest.fixture +def batch_write_item_put_request(expect_standard_dictionaries, multiple_test_items): + if expect_standard_dictionaries: + return basic_batch_write_item_put_request_dict(multiple_test_items) + return basic_batch_write_item_put_request_ddb(multiple_test_items) + + +@pytest.fixture +def batch_write_item_delete_request(expect_standard_dictionaries, multiple_test_keys): + if expect_standard_dictionaries: + return basic_batch_write_item_delete_request_dict(multiple_test_keys) + return basic_batch_write_item_delete_request_ddb(multiple_test_keys) + + +@pytest.fixture +def batch_get_item_request(expect_standard_dictionaries, multiple_test_keys): + if expect_standard_dictionaries: + return basic_batch_get_item_request_dict(multiple_test_keys) + return basic_batch_get_item_request_ddb(multiple_test_keys) + + +def test_GIVEN_valid_batch_write_and_get_requests_WHEN_batch_write_and_get_THEN_round_trip_passes( + client, multiple_test_items, batch_write_item_put_request, batch_get_item_request, batch_write_item_delete_request +): + # Given: Valid batch_write_item put request + # When: batch_write_item put + batch_write_response = client.batch_write_item(**batch_write_item_put_request) + # Then: batch_write_item put succeeds + assert batch_write_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid batch_get_item request + # When: batch_get_item + batch_get_response = client.batch_get_item(**batch_get_item_request) + # Then: batch_get_item succeeds + assert batch_get_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + # Then: All items are encrypted and decrypted correctly + retrieved_items = batch_get_response["Responses"][INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME] + assert len(retrieved_items) > 0 + assert len(retrieved_items) == len(multiple_test_items) + expected_items = [sort_dynamodb_json_lists(expected_item) for expected_item in multiple_test_items] + actual_items = [sort_dynamodb_json_lists(actual_item) for actual_item in retrieved_items] + for actual_item in actual_items: + assert actual_item in expected_items + + # Given: Valid batch_delete_item request + # When: batch_delete_item + batch_delete_response = client.batch_write_item(**batch_write_item_delete_request) + # Then: batch_delete_item succeeds + assert batch_delete_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid batch_get_item request + # When: batch_get_item + batch_get_response = client.batch_get_item(**batch_get_item_request) + # Then: batch_get_item succeeds + assert batch_get_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + # Then: All items are deleted + retrieved_items = batch_get_response["Responses"] + + +@pytest.fixture +def query_request(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + return {**basic_query_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME} + return basic_query_request_ddb(test_item) + + +def test_GIVEN_valid_put_and_query_requests_WHEN_put_and_query_THEN_round_trip_passes( + client, put_item_request, query_request +): + # Given: Valid put_item request + # When: put_item + put_response = client.put_item(**put_item_request) + # Then: put_item succeeds + assert put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid query request + # When: query + query_response = client.query(**query_request) + # Then: query succeeds + assert query_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert len(query_response["Items"]) == 1 + # DynamoDB JSON uses lists to represent sets, so strict equality can fail. + # Sort lists to ensure consistent ordering when comparing expected and actual items. + expected_item = sort_dynamodb_json_lists(put_item_request["Item"]) + actual_item = sort_dynamodb_json_lists(query_response["Items"][0]) + assert expected_item == actual_item + + +@pytest.fixture +def scan_request(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + return {**basic_scan_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME} + return basic_scan_request_ddb(test_item) + + +def test_GIVEN_valid_put_and_scan_requests_WHEN_put_and_scan_THEN_round_trip_passes( + client, put_item_request, scan_request +): + # Given: Valid put_item request + # When: put_item + put_response = client.put_item(**put_item_request) + # Then: put_item succeeds + assert put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid scan request + # When: scan + scan_response = client.scan(**scan_request) + # Then: scan succeeds + assert scan_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert len(scan_response["Items"]) >= 1 + # Can't assert anything about the scan; + # there are too many items. + # The critical assertion is that the scan succeeds. + + +@pytest.fixture +def transact_write_item_put_request(expect_standard_dictionaries, multiple_test_items): + if expect_standard_dictionaries: + return basic_transact_write_item_put_request_dict(multiple_test_items) + return basic_transact_write_item_put_request_ddb(multiple_test_items) + + +@pytest.fixture +def transact_write_item_delete_request(expect_standard_dictionaries, multiple_test_keys): + if expect_standard_dictionaries: + return basic_transact_write_item_delete_request_dict(multiple_test_keys) + return basic_transact_write_item_delete_request_ddb(multiple_test_keys) + + +@pytest.fixture +def transact_get_item_request(expect_standard_dictionaries, multiple_test_keys): + if expect_standard_dictionaries: + return basic_transact_get_item_request_dict(multiple_test_keys) + return basic_transact_get_item_request_ddb(multiple_test_keys) + + +def test_GIVEN_valid_transact_write_and_get_requests_WHEN_transact_write_and_get_THEN_round_trip_passes( + client, + multiple_test_items, + transact_write_item_put_request, + transact_write_item_delete_request, + transact_get_item_request, + batch_get_item_request, +): + # Given: Valid transact_write_item put request + # When: transact_write_item put + transact_write_put_response = client.transact_write_items(**transact_write_item_put_request) + # Then: transact_write_item put succeeds + assert transact_write_put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Valid transact_get_item request + # When: transact_get_item + transact_get_response = client.transact_get_items(**transact_get_item_request) + # Then: transact_get_item succeeds + assert transact_get_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + # Then: All items are encrypted and decrypted correctly + retrieved_items = transact_get_response["Responses"] + assert len(retrieved_items) > 0 + assert len(retrieved_items) == len(multiple_test_items) + expected_items = [sort_dynamodb_json_lists(expected_item) for expected_item in multiple_test_items] + actual_items = [sort_dynamodb_json_lists(actual_item) for actual_item in retrieved_items] + for actual_item in actual_items: + assert actual_item["Item"] in expected_items + + # Given: Valid transact_write_item delete request + # When: transact_write_item delete + transact_write_delete_response = client.transact_write_items(**transact_write_item_delete_request) + # Then: transact_write_item delete succeeds + assert transact_write_delete_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@pytest.fixture +def update_item_request_unsigned_attribute(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + return { + **basic_update_item_request_dict_unsigned_attribute(test_item), + "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME, + } + return basic_update_item_request_ddb_unsigned_attribute(test_item) + + +def test_WHEN_update_item_with_unsigned_attribute_THEN_passes( + client, update_item_request_unsigned_attribute, encrypted, get_item_request +): + # Given: Valid update_item request + # When: update_item + update_response = client.update_item(**update_item_request_unsigned_attribute) + # Then: update_item succeeds + assert update_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@pytest.fixture +def update_item_request_signed_attribute(expect_standard_dictionaries, test_item): + if expect_standard_dictionaries: + return { + **basic_update_item_request_dict_signed_attribute(test_item), + "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME, + } + return basic_update_item_request_ddb_signed_attribute(test_item) + + +def test_WHEN_update_item_with_signed_attribute_THEN_raises_DynamoDbEncryptionTransformsException( + client, + update_item_request_signed_attribute, + encrypted, +): + if not encrypted: + pytest.skip("Skipping negative test for plaintext client") + + # Given: Encrypted client and update item parameters + # Then: DynamoDbEncryptionTransformsException is raised + with pytest.raises(DynamoDbEncryptionTransformsException): + # When: Calling update_item + client.update_item(**update_item_request_signed_attribute) + + +# Create a matrix of tests for each value in param, +# with a user-friendly string for test output: +# execute_uses_encrypted_table = True -> "encrypted_table" +# execute_uses_encrypted_table = False -> "plaintext_table" +# This indicates whether an execute_(statement,transaction,etc.) operation should be on an encrypted table +@pytest.fixture(params=[True, False], ids=["encrypted_table", "plaintext_table"]) +def execute_uses_encrypted_table(request): + return request.param + + +@pytest.fixture +def execute_statement_request(execute_uses_encrypted_table): + if execute_uses_encrypted_table: + return basic_execute_statement_request_encrypted_table() + return basic_execute_statement_request_plaintext_table() + + +def test_WHEN_execute_statement_for_encrypted_table_THEN_raises_DynamoDbEncryptionTransformsException( + client, + execute_statement_request, + encrypted, + execute_uses_encrypted_table, +): + if not encrypted: + pytest.skip("Skipping negative test for plaintext client") + + if execute_uses_encrypted_table: + # Given: Encrypted client and execute_statement request on encrypted table + # Then: DynamoDbEncryptionTransformsException is raised + with pytest.raises(DynamoDbEncryptionTransformsException): + # When: Calling execute_statement + client.execute_statement(**execute_statement_request) + else: + pytest.skip("Skipping test for plaintext table; this test is only for encrypted tables") + + +def test_WHEN_execute_statement_for_plaintext_table_THEN_passes( + client, + execute_statement_request, + execute_uses_encrypted_table, +): + if execute_uses_encrypted_table: + pytest.skip("Skipping test for encrypted table; this test is only for plaintext tables") + + # Given: Client calls execute_statement on plaintext table + # When: Calling execute_statement + response = client.execute_statement(**execute_statement_request) + # Then: Success + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@pytest.fixture +def execute_transaction_request(execute_uses_encrypted_table, test_item): + if execute_uses_encrypted_table: + return basic_execute_transaction_request_encrypted_table(test_item) + return basic_execute_transaction_request_plaintext_table(test_item) + + +def test_WHEN_execute_transaction_for_encrypted_table_THEN_raises_DynamoDbEncryptionTransformsException( + client, + execute_transaction_request, + encrypted, + execute_uses_encrypted_table, +): + if not encrypted: + pytest.skip("Skipping negative test for plaintext client") + + if execute_uses_encrypted_table: + # Given: Encrypted client and execute_transaction request on encrypted table + # Then: DynamoDbEncryptionTransformsException is raised + with pytest.raises(DynamoDbEncryptionTransformsException): + # When: Calling execute_transaction + client.execute_transaction(**execute_transaction_request) + else: + pytest.skip("Skipping test for plaintext table; this test is only for encrypted tables") + + +def test_WHEN_execute_transaction_for_plaintext_table_THEN_passes( + client, + execute_transaction_request, + execute_uses_encrypted_table, + put_item_request, +): + if execute_uses_encrypted_table: + pytest.skip("Skipping test for encrypted table; this test is only for plaintext tables") + + put_response = client.put_item(**put_item_request) + assert put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # Given: Client calls execute_transaction on plaintext table + # When: Calling execute_transaction + response = client.execute_transaction(**execute_transaction_request) + # Then: Success + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@pytest.fixture +def batch_execute_statement_request(execute_uses_encrypted_table): + if execute_uses_encrypted_table: + return basic_batch_execute_statement_request_encrypted_table() + return basic_batch_execute_statement_request_plaintext_table() + + +def test_WHEN_batch_execute_statement_for_encrypted_table_THEN_raises_DynamoDbEncryptionTransformsException( + client, + batch_execute_statement_request, + encrypted, + execute_uses_encrypted_table, +): + if not encrypted: + pytest.skip("Skipping negative test for plaintext client") + + if execute_uses_encrypted_table: + # Given: Encrypted client and batch_execute_statement request on encrypted table + # Then: DynamoDbEncryptionTransformsException is raised + with pytest.raises(DynamoDbEncryptionTransformsException): + # When: Calling batch_execute_statement + client.batch_execute_statement(**batch_execute_statement_request) + else: + pytest.skip("Skipping test for plaintext table; this test is only for encrypted tables") + + +def test_WHEN_batch_execute_statement_for_plaintext_table_THEN_passes( + client, + batch_execute_statement_request, + execute_uses_encrypted_table, +): + if execute_uses_encrypted_table: + pytest.skip("Skipping test for encrypted table; this test is only for plaintext tables") + + # Given: Client calls batch_execute_statement on plaintext table + # When: Calling batch_execute_statement + response = client.batch_execute_statement(**batch_execute_statement_request) + # Then: Success + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +def test_WHEN_get_paginator_THEN_correct_paginator_is_returned(): + """Test get_paginator for scan and query operations.""" + # Given: Encrypted client + + # When: Getting paginator for some encrypted operation + scan_paginator = encrypted_client(expect_standard_dictionaries=False).get_paginator("query") + # Then: EncryptedPaginator is returned + assert isinstance(scan_paginator, EncryptedPaginator) + + # When: Getting paginator for some non-encrypted operation + list_backups_paginator = encrypted_client(expect_standard_dictionaries=False).get_paginator("list_backups") + # Then: Query paginator is returned + assert not isinstance(list_backups_paginator, EncryptedPaginator) + + +def test_WHEN_call_passthrough_method_THEN_correct_response_is_returned(): + """Test that calling a passthrough method returns the correct response.""" + # Given: Encrypted client + # When: Calling some passthrough method that does not explicitly exist on EncryptedClient, + # but exists on the underlying boto3 client + response = encrypted_client(expect_standard_dictionaries=False).list_backups() + # Then: Correct response is returned, i.e. EncryptedClient forwards the call to the underlying boto3 client + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 diff --git a/DynamoDbEncryption/runtimes/python/test/unit/encrypted/test_client.py b/DynamoDbEncryption/runtimes/python/test/unit/encrypted/test_client.py new file mode 100644 index 000000000..9b2275c5c --- /dev/null +++ b/DynamoDbEncryption/runtimes/python/test/unit/encrypted/test_client.py @@ -0,0 +1,41 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""High-level helper class to provide an encrypting wrapper for boto3 DynamoDB clients.""" +import pytest +from botocore.client import BaseClient +from mock import MagicMock + +from aws_dbesdk_dynamodb.encrypted.client import ( + EncryptedClient, +) +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.models import ( + DynamoDbTablesEncryptionConfig, +) + +mock_boto3_dynamodb_client = MagicMock(__class__=BaseClient) +mock_tables_encryption_config = MagicMock(__class__=DynamoDbTablesEncryptionConfig) + + +def test_GIVEN_valid_inputs_WHEN_create_EncryptedClient_THEN_success(): + # Given: Valid EncryptedClient inputs + # When: Create EncryptedClient + EncryptedClient( + client=mock_boto3_dynamodb_client, + encryption_config=mock_tables_encryption_config, + ) + # Then: Success + + +def test_GIVEN_invalid_class_attribute_WHEN_getattr_THEN_raise_error(): + # Create a mock with a specific spec that excludes our unknown attribute + mock_boto3_dynamodb_client = MagicMock(spec=["put_item", "get_item", "query", "scan"]) + encrypted_client = EncryptedClient( + client=mock_boto3_dynamodb_client, + encryption_config=mock_tables_encryption_config, + ) + + # Then: AttributeError is raised + with pytest.raises(AttributeError): + # Given: Invalid class attribute: not_a_valid_attribute_on_EncryptedClient_nor_boto3_client + # When: getattr is called + encrypted_client.not_a_valid_attribute_on_EncryptedClient_nor_boto3_client() diff --git a/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/__init__.py b/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/__init__.py new file mode 100644 index 000000000..fa977e22f --- /dev/null +++ b/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/__init__.py @@ -0,0 +1,3 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Stub to allow relative imports of examples from tests.""" diff --git a/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/with_encrypted_client.py b/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/with_encrypted_client.py new file mode 100644 index 000000000..04c361e2b --- /dev/null +++ b/Examples/runtimes/python/DynamoDBEncryption/src/basic_put_get_example/with_encrypted_client.py @@ -0,0 +1,160 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +Example for using an EncryptedClient to put and get an encrypted item. + +Running this example requires access to the DDB Table whose name +is provided in the function arguments. +This table must be configured with the following +primary key configuration: +- Partition key is named "partition_key" with type (S) +- Sort key is named "sort_key" with type (N) +""" + +import boto3 +from aws_cryptographic_material_providers.mpl import AwsCryptographicMaterialProviders +from aws_cryptographic_material_providers.mpl.config import MaterialProvidersConfig +from aws_cryptographic_material_providers.mpl.models import ( + CreateAwsKmsMrkMultiKeyringInput, + DBEAlgorithmSuiteId, +) +from aws_cryptographic_material_providers.mpl.references import IKeyring +from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient +from aws_dbesdk_dynamodb.structures.dynamodb import ( + DynamoDbTableEncryptionConfig, + DynamoDbTablesEncryptionConfig, +) +from aws_dbesdk_dynamodb.structures.structured_encryption import ( + CryptoAction, +) + + +def encrypted_client_put_get_example( + kms_key_id: str, + dynamodb_table_name: str, +): + """Use an EncryptedClient to put and get an encrypted item.""" + # 1. Create a Keyring. This Keyring will be responsible for protecting the data keys that protect your data. + # For this example, we will create a AWS KMS Keyring with the AWS KMS Key we want to use. + # We will use the `CreateMrkMultiKeyring` method to create this keyring, + # as it will correctly handle both single region and Multi-Region KMS Keys. + mat_prov: AwsCryptographicMaterialProviders = AwsCryptographicMaterialProviders(config=MaterialProvidersConfig()) + kms_mrk_multi_keyring_input: CreateAwsKmsMrkMultiKeyringInput = CreateAwsKmsMrkMultiKeyringInput( + generator=kms_key_id, + ) + kms_mrk_multi_keyring: IKeyring = mat_prov.create_aws_kms_mrk_multi_keyring(input=kms_mrk_multi_keyring_input) + + # 2. Configure which attributes are encrypted and/or signed when writing new items. + # For each attribute that may exist on the items we plan to write to our DynamoDbTable, + # we must explicitly configure how they should be treated during item encryption: + # - ENCRYPT_AND_SIGN: The attribute is encrypted and included in the signature + # - SIGN_ONLY: The attribute not encrypted, but is still included in the signature + # - DO_NOTHING: The attribute is not encrypted and not included in the signature + attribute_actions_on_encrypt = { + "partition_key": CryptoAction.SIGN_ONLY, + "sort_key": CryptoAction.SIGN_ONLY, + "attribute1": CryptoAction.ENCRYPT_AND_SIGN, + "attribute2": CryptoAction.SIGN_ONLY, + ":attribute3": CryptoAction.DO_NOTHING, + } + + # 3. Configure which attributes we expect to be included in the signature + # when reading items. There are two options for configuring this: + # + # - (Recommended) Configure `allowedUnsignedAttributesPrefix`: + # When defining your DynamoDb schema and deciding on attribute names, + # choose a distinguishing prefix (such as ":") for all attributes that + # you do not want to include in the signature. + # This has two main benefits: + # - It is easier to reason about the security and authenticity of data within your item + # when all unauthenticated data is easily distinguishable by their attribute name. + # - If you need to add new unauthenticated attributes in the future, + # you can easily make the corresponding update to your `attributeActionsOnEncrypt` + # and immediately start writing to that new attribute, without + # any other configuration update needed. + # Once you configure this field, it is not safe to update it. + # + # - Configure `allowedUnsignedAttributes`: You may also explicitly list + # a set of attributes that should be considered unauthenticated when encountered + # on read. Be careful if you use this configuration. Do not remove an attribute + # name from this configuration, even if you are no longer writing with that attribute, + # as old items may still include this attribute, and our configuration needs to know + # to continue to exclude this attribute from the signature scope. + # If you add new attribute names to this field, you must first deploy the update to this + # field to all readers in your host fleet before deploying the update to start writing + # with that new attribute. + # + # For this example, we have designed our DynamoDb table such that any attribute name with + # the ":" prefix should be considered unauthenticated. + unsignAttrPrefix: str = ":" + + # 4. Create the DynamoDb Encryption configuration for the table we will be writing to. + table_configs = {} + table_config = DynamoDbTableEncryptionConfig( + logical_table_name=dynamodb_table_name, + partition_key_name="partition_key", + sort_key_name="sort_key", + attribute_actions_on_encrypt=attribute_actions_on_encrypt, + keyring=kms_mrk_multi_keyring, + allowed_unsigned_attribute_prefix=unsignAttrPrefix, + # Specifying an algorithm suite is not required, + # but is done here to demonstrate how to do so. + # We suggest using the + # `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384_SYMSIG_HMAC_SHA384` suite, + # which includes AES-GCM with key derivation, signing, and key commitment. + # This is also the default algorithm suite if one is not specified in this config. + # For more information on supported algorithm suites, see: + # https://docs.aws.amazon.com/database-encryption-sdk/latest/devguide/supported-algorithms.html + algorithm_suite_id=DBEAlgorithmSuiteId.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY_ECDSA_P384_SYMSIG_HMAC_SHA384, + ) + table_configs[dynamodb_table_name] = table_config + tables_config = DynamoDbTablesEncryptionConfig(table_encryption_configs=table_configs) + + # 5. Create the EncryptedClient + encrypted_client = EncryptedClient( + client=boto3.client("dynamodb"), + encryption_config=tables_config, + ) + + # 6. Put an item into our table using the above client. + # Before the item gets sent to DynamoDb, it will be encrypted + # client-side, according to our configuration. + item_to_encrypt = { + "partition_key": {"S": "BasicPutGetExample"}, + "sort_key": {"N": "0"}, + "attribute1": {"S": "encrypt and sign me!"}, + "attribute2": {"S": "sign me!"}, + ":attribute3": {"S": "ignore me!"}, + } + + put_item_request = { + "TableName": dynamodb_table_name, + "Item": item_to_encrypt, + } + + put_item_response = encrypted_client.put_item(**put_item_request) + + # Demonstrate that PutItem succeeded + assert put_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # 7. Get the item back from our table using the same client. + # The client will decrypt the item client-side, and return + # back the original item. + key_to_get = {"partition_key": {"S": "BasicPutGetExample"}, "sort_key": {"N": "0"}} + + get_item_request = {"TableName": dynamodb_table_name, "Key": key_to_get} + + get_item_response = encrypted_client.get_item(**get_item_request) + + # Demonstrate that GetItem succeeded and returned the decrypted item + assert get_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert get_item_response["Item"] == item_to_encrypt + + # 8. Clean up the item we put into the table by deleting it. + delete_item_request = {"TableName": dynamodb_table_name, "Key": key_to_get} + delete_item_response = encrypted_client.delete_item(**delete_item_request) + + # Demonstrate that DeleteItem succeeded + assert delete_item_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + get_item_response = encrypted_client.get_item(**get_item_request) + assert "Item" not in get_item_response diff --git a/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/__init__.py b/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/__init__.py new file mode 100644 index 000000000..fa977e22f --- /dev/null +++ b/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/__init__.py @@ -0,0 +1,3 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Stub to allow relative imports of examples from tests.""" diff --git a/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/test_with_encrypted_client.py b/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/test_with_encrypted_client.py new file mode 100644 index 000000000..d18ffe823 --- /dev/null +++ b/Examples/runtimes/python/DynamoDBEncryption/test/basic_put_get_example/test_with_encrypted_client.py @@ -0,0 +1,15 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Test suite for the EncryptedClient example.""" +import pytest + +from ...src.basic_put_get_example.with_encrypted_client import encrypted_client_put_get_example + +pytestmark = [pytest.mark.examples] + + +def test_encrypted_client_put_get_example(): + """Test function for encrypt and decrypt using the EncryptedClient example.""" + test_kms_key_id = "arn:aws:kms:us-west-2:658956600833:key/b3537ef1-d8dc-4780-9f5a-55776cbb2f7f" + test_dynamodb_table_name = "DynamoDbEncryptionInterceptorTestTable" + encrypted_client_put_get_example(test_kms_key_id, test_dynamodb_table_name) diff --git a/TestVectors/runtimes/python/src/aws_dbesdk_dynamodb_test_vectors/internaldafny/extern/CreateInterceptedDDBClient.py b/TestVectors/runtimes/python/src/aws_dbesdk_dynamodb_test_vectors/internaldafny/extern/CreateInterceptedDDBClient.py new file mode 100644 index 000000000..7f3ed8d31 --- /dev/null +++ b/TestVectors/runtimes/python/src/aws_dbesdk_dynamodb_test_vectors/internaldafny/extern/CreateInterceptedDDBClient.py @@ -0,0 +1,28 @@ +import boto3 +import aws_dbesdk_dynamodb_test_vectors.internaldafny.generated.CreateInterceptedDDBClient +import aws_cryptography_internal_dynamodb.internaldafny.extern +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.dafny_to_smithy import aws_cryptography_dbencryptionsdk_dynamodb_DynamoDbTablesEncryptionConfig +from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient +from smithy_dafny_standard_library.internaldafny.generated import Wrappers +from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.errors import _smithy_error_to_dafny_error +from aws_dbesdk_dynamodb_test_vectors.waiting_boto3_ddb_client import WaitingLocalDynamoClient + +class default__: + @staticmethod + def CreateVanillaDDBClient(): + try: + return aws_cryptography_internal_dynamodb.internaldafny.extern.Com_Amazonaws_Dynamodb.default__.DynamoDBClient(WaitingLocalDynamoClient()) + except Exception as e: + return Wrappers.Result_Failure(_smithy_error_to_dafny_error(e)) + + @staticmethod + def CreateInterceptedDDBClient(dafny_encryption_config): + try: + native_encryption_config = aws_cryptography_dbencryptionsdk_dynamodb_DynamoDbTablesEncryptionConfig(dafny_encryption_config) + boto3_client = WaitingLocalDynamoClient() + encrypted_client = EncryptedClient(client = boto3_client, encryption_config = native_encryption_config) + return aws_cryptography_internal_dynamodb.internaldafny.extern.Com_Amazonaws_Dynamodb.default__.DynamoDBClient(encrypted_client) + except Exception as e: + return Wrappers.Result_Failure(_smithy_error_to_dafny_error(e)) + +aws_dbesdk_dynamodb_test_vectors.internaldafny.generated.CreateInterceptedDDBClient.default__ = default__ diff --git a/TestVectors/runtimes/python/test/__init__.py b/TestVectors/runtimes/python/test/__init__.py new file mode 100644 index 000000000..f94fd12a2 --- /dev/null +++ b/TestVectors/runtimes/python/test/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/TestVectors/runtimes/python/test/client/__init__.py b/TestVectors/runtimes/python/test/client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/TestVectors/runtimes/python/test/client/test_dafny_wrapper.py b/TestVectors/runtimes/python/test/client/test_dafny_wrapper.py new file mode 100644 index 000000000..73a6607ca --- /dev/null +++ b/TestVectors/runtimes/python/test/client/test_dafny_wrapper.py @@ -0,0 +1,26 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +""" +Wrapper file for executing Dafny tests from pytest. +This allows us to import modules required by Dafny-generated tests +before executing Dafny-generated tests. +pytest will find and execute the `test_dafny` method below, +which will execute the `internaldafny_test_executor.py` file in the `dafny` directory. +""" + +import sys + +# Different from standard test_dafny_wrapper due to weird test structure. +test_dir = '/'.join(__file__.split("/")[:-2]) + +sys.path.append(test_dir + "/internaldafny/extern") +sys.path.append(test_dir + "/internaldafny/generated") + +# Import extern to use an EncryptedClient as the wrapped DBESDK client. +import aws_dbesdk_dynamodb_test_vectors.internaldafny.extern.CreateInterceptedDDBClient +# Import extern to use the ItemEncryptor with DDB JSON-formatted items. +# (EncryptedClients use DDB JSON-formatted items by default.) +import aws_dbesdk_dynamodb_test_vectors.internaldafny.extern.CreateWrappedDynamoDbItemEncryptor + +def test_dafny(): + from ..internaldafny.generated import __main__ \ No newline at end of file