Skip to content

Commit 9e4bd6f

Browse files
author
Lucas McDonald
committed
sync
1 parent 1b0bdff commit 9e4bd6f

File tree

7 files changed

+647
-0
lines changed

7 files changed

+647
-0
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""High-level helper class to provide an encrypting wrapper for boto3 DynamoDB paginators."""
4+
from collections.abc import Callable, Generator
5+
from copy import deepcopy
6+
from typing import Any
7+
8+
from botocore.paginate import (
9+
Paginator,
10+
)
11+
12+
from aws_dbesdk_dynamodb.encrypted.boto3_interface import EncryptedBotoInterface
13+
from aws_dbesdk_dynamodb.internal.client_to_resource import ClientShapeToResourceShapeConverter
14+
from aws_dbesdk_dynamodb.internal.resource_to_client import ResourceShapeToClientShapeConverter
15+
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb.models import (
16+
DynamoDbTablesEncryptionConfig,
17+
)
18+
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb_transforms.client import (
19+
DynamoDbEncryptionTransforms,
20+
)
21+
from aws_dbesdk_dynamodb.smithygenerated.aws_cryptography_dbencryptionsdk_dynamodb_transforms.models import (
22+
QueryInputTransformInput,
23+
QueryOutputTransformInput,
24+
ScanInputTransformInput,
25+
ScanOutputTransformInput,
26+
)
27+
28+
29+
class EncryptedPaginator(EncryptedBotoInterface):
30+
"""Wrapping class for the boto3 Paginator that decrypts returned items before returning them."""
31+
32+
def __init__(
33+
self,
34+
*,
35+
paginator: Paginator,
36+
encryption_config: DynamoDbTablesEncryptionConfig,
37+
expect_standard_dictionaries: bool | None = False,
38+
):
39+
"""
40+
Create an EncryptedPaginator.
41+
42+
Args:
43+
paginator (Paginator): A boto3 Paginator object for DynamoDB operations.
44+
This can be either a "query" or "scan" Paginator.
45+
encryption_config (DynamoDbTablesEncryptionConfig): Encryption configuration object.
46+
expect_standard_dictionaries (Optional[bool]): Does the underlying boto3 client expect items
47+
to be standard Python dictionaries? This should only be set to True if you are using a
48+
client obtained from a service resource or table resource (ex: ``table.meta.client``).
49+
If this is True, EncryptedClient will expect item-like shapes to be
50+
standard Python dictionaries (default: False).
51+
52+
"""
53+
self._paginator = paginator
54+
self._encryption_config = encryption_config
55+
self._transformer = DynamoDbEncryptionTransforms(config=encryption_config)
56+
self._expect_standard_dictionaries = expect_standard_dictionaries
57+
self._resource_to_client_shape_converter = ResourceShapeToClientShapeConverter()
58+
self._client_to_resource_shape_converter = ClientShapeToResourceShapeConverter(delete_table_name=False)
59+
60+
def paginate(self, **kwargs) -> Generator[dict, None, None]:
61+
"""
62+
Yield a generator that paginates through responses from DynamoDB, decrypting items.
63+
64+
Note:
65+
Calling ``botocore.paginate.Paginator``'s ``paginate`` method for Query or Scan
66+
returns a ``PageIterator`` object, but this implementation returns a Python generator.
67+
However, you can use this generator to iterate exactly as described in the
68+
boto3 documentation:
69+
70+
https://botocore.amazonaws.com/v1/documentation/api/latest/topics/paginators.html
71+
72+
Any other operations on this class will defer to the underlying boto3 Paginator's implementation.
73+
74+
Args:
75+
**kwargs: Keyword arguments passed directly to the underlying DynamoDB paginator.
76+
77+
For a Scan operation, structure these arguments according to:
78+
79+
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/paginator/Scan.html
80+
81+
For a Query operation, structure these arguments according to:
82+
83+
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/paginator/Query.html
84+
85+
Returns:
86+
Generator[dict, None, None]: A generator yielding pages as dictionaries.
87+
The items in the pages will be decrypted locally after being read from DynamoDB.
88+
89+
"""
90+
if self._paginator._model.name == "Query":
91+
yield from self._paginate_query(**kwargs)
92+
elif self._paginator._model.name == "Scan":
93+
yield from self._paginate_scan(**kwargs)
94+
else:
95+
yield from self._paginator.paginate(**kwargs)
96+
97+
def _paginate_query(self, **paginate_query_kwargs):
98+
return self._paginate_request(
99+
paginate_kwargs=paginate_query_kwargs,
100+
input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.query_request,
101+
input_item_to_dict_transform_method=self._client_to_resource_shape_converter.query_request,
102+
input_transform_method=self._transformer.query_input_transform,
103+
input_transform_shape=QueryInputTransformInput,
104+
output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.query_response,
105+
output_item_to_dict_transform_method=self._client_to_resource_shape_converter.query_response,
106+
output_transform_method=self._transformer.query_output_transform,
107+
output_transform_shape=QueryOutputTransformInput,
108+
)
109+
110+
def _paginate_scan(self, **paginate_scan_kwargs):
111+
return self._paginate_request(
112+
paginate_kwargs=paginate_scan_kwargs,
113+
input_item_to_ddb_transform_method=self._resource_to_client_shape_converter.scan_request,
114+
input_item_to_dict_transform_method=self._client_to_resource_shape_converter.scan_request,
115+
input_transform_method=self._transformer.scan_input_transform,
116+
input_transform_shape=ScanInputTransformInput,
117+
output_item_to_ddb_transform_method=self._resource_to_client_shape_converter.scan_response,
118+
output_item_to_dict_transform_method=self._client_to_resource_shape_converter.scan_response,
119+
output_transform_method=self._transformer.scan_output_transform,
120+
output_transform_shape=ScanOutputTransformInput,
121+
)
122+
123+
def _paginate_request(
124+
self,
125+
*,
126+
paginate_kwargs: dict[str, Any],
127+
input_item_to_ddb_transform_method: Callable,
128+
input_item_to_dict_transform_method: Callable,
129+
input_transform_method: Callable,
130+
input_transform_shape: Any,
131+
output_item_to_ddb_transform_method: Callable,
132+
output_item_to_dict_transform_method: Callable,
133+
output_transform_method: Callable,
134+
output_transform_shape: Any,
135+
):
136+
client_kwargs = deepcopy(paginate_kwargs)
137+
try:
138+
# Remove PaginationConfig from the request if it exists.
139+
# The input_transform_method does not expect it.
140+
# It is added back to the request sent to the SDK.
141+
pagination_config = client_kwargs["PaginationConfig"]
142+
del client_kwargs["PaginationConfig"]
143+
except KeyError:
144+
pagination_config = None
145+
146+
# If _expect_standard_dictionaries is true, input items are expected to be standard dictionaries,
147+
# and need to be converted to DDB-JSON before encryption.
148+
if self._expect_standard_dictionaries:
149+
if "TableName" in client_kwargs:
150+
self._resource_to_client_shape_converter.table_name = client_kwargs["TableName"]
151+
client_kwargs = input_item_to_ddb_transform_method(client_kwargs)
152+
153+
# Apply DBESDK transformations to the input
154+
transformed_request = input_transform_method(input_transform_shape(sdk_input=client_kwargs)).transformed_input
155+
156+
# If _expect_standard_dictionaries is true, the boto3 client expects items to be standard dictionaries,
157+
# and need to be converted from DDB-JSON to a standard dictionary before being passed to the boto3 client.
158+
if self._expect_standard_dictionaries:
159+
transformed_request = input_item_to_dict_transform_method(transformed_request)
160+
161+
if pagination_config is not None:
162+
transformed_request["PaginationConfig"] = pagination_config
163+
164+
sdk_page_response = self._paginator.paginate(**transformed_request)
165+
166+
for page in sdk_page_response:
167+
# If _expect_standard_dictionaries is true, the boto3 client returns items as standard dictionaries,
168+
# and needs to convert the standard dictionary to DDB-JSON before passing the response to the DBESDK.
169+
if self._expect_standard_dictionaries:
170+
page = output_item_to_ddb_transform_method(page)
171+
172+
# Apply DBESDK transformation to the boto3 output
173+
dbesdk_response = output_transform_method(
174+
output_transform_shape(
175+
original_input=client_kwargs,
176+
sdk_output=page,
177+
)
178+
).transformed_output
179+
180+
# Copy any missing fields from the SDK output to the response (e.g. ConsumedCapacity)
181+
dbesdk_response = self._copy_sdk_response_to_dbesdk_response(page, dbesdk_response)
182+
183+
# If _expect_standard_dictionaries is true, the boto3 client expects items to be standard dictionaries,
184+
# and need to be converted from DDB-JSON to a standard dictionary before returning the response.
185+
if self._expect_standard_dictionaries:
186+
dbesdk_response = output_item_to_dict_transform_method(dbesdk_response)
187+
188+
yield dbesdk_response
189+
190+
@property
191+
def _boto_client_attr_name(self) -> str:
192+
"""
193+
Name of the attribute containing the underlying boto3 client.
194+
195+
Returns:
196+
str: '_paginator'
197+
198+
"""
199+
return "_paginator"
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import boto3
2+
import pytest
3+
4+
from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient
5+
6+
from ...constants import (
7+
INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME,
8+
INTEG_TEST_DEFAULT_TABLE_CONFIGS,
9+
)
10+
from ...items import (
11+
complex_item_ddb,
12+
complex_item_dict,
13+
complex_key_ddb,
14+
complex_key_dict,
15+
simple_item_ddb,
16+
simple_item_dict,
17+
simple_key_ddb,
18+
simple_key_dict,
19+
)
20+
from ...requests import (
21+
basic_put_item_request_ddb,
22+
basic_put_item_request_dict,
23+
basic_query_paginator_request,
24+
basic_scan_paginator_request,
25+
)
26+
from . import sort_dynamodb_json_lists
27+
28+
29+
# Creates a matrix of tests for each value in param,
30+
# with a user-friendly string for test output:
31+
# expect_standard_dictionaries = True -> "standard_dicts"
32+
# expect_standard_dictionaries = False -> "ddb_json"
33+
@pytest.fixture(params=[True, False], ids=["standard_dicts", "ddb_json"])
34+
def expect_standard_dictionaries(request):
35+
return request.param
36+
37+
38+
def encrypted_client(expect_standard_dictionaries):
39+
return EncryptedClient(
40+
client=plaintext_client(expect_standard_dictionaries),
41+
encryption_config=INTEG_TEST_DEFAULT_TABLE_CONFIGS,
42+
expect_standard_dictionaries=expect_standard_dictionaries,
43+
)
44+
45+
46+
def plaintext_client(expect_standard_dictionaries):
47+
if expect_standard_dictionaries:
48+
client = boto3.resource("dynamodb").Table(INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME).meta.client
49+
else:
50+
client = boto3.client("dynamodb")
51+
return client
52+
53+
54+
# Creates a matrix of tests for each value in param,
55+
# with a user-friendly string for test output:
56+
# encrypted = True -> "encrypted"
57+
# encrypted = False -> "plaintext"
58+
@pytest.fixture(params=[True, False], ids=["encrypted", "plaintext"])
59+
def encrypted(request):
60+
return request.param
61+
62+
63+
@pytest.fixture
64+
def client(encrypted, expect_standard_dictionaries):
65+
if encrypted:
66+
return encrypted_client(expect_standard_dictionaries)
67+
else:
68+
return plaintext_client(expect_standard_dictionaries)
69+
70+
71+
@pytest.fixture
72+
def query_paginator(client):
73+
return client.get_paginator("query")
74+
75+
76+
@pytest.fixture
77+
def scan_paginator(client):
78+
return client.get_paginator("scan")
79+
80+
81+
# Creates a matrix of tests for each value in param,
82+
# with a user-friendly string for test output:
83+
# use_complex_item = True -> "complex_item"
84+
# use_complex_item = False -> "simple_item"
85+
@pytest.fixture(params=[True, False], ids=["complex_item", "simple_item"])
86+
def use_complex_item(request):
87+
return request.param
88+
89+
90+
@pytest.fixture
91+
def test_key(expect_standard_dictionaries, use_complex_item):
92+
"""Get a single test item in the appropriate format for the client."""
93+
if expect_standard_dictionaries:
94+
if use_complex_item:
95+
return complex_key_dict
96+
return simple_key_dict
97+
if use_complex_item:
98+
return complex_key_ddb
99+
return simple_key_ddb
100+
101+
102+
@pytest.fixture
103+
def multiple_test_keys(expect_standard_dictionaries):
104+
"""Get two test keys in the appropriate format for the client."""
105+
if expect_standard_dictionaries:
106+
return [simple_key_dict, complex_key_dict]
107+
return [simple_key_ddb, complex_key_ddb]
108+
109+
110+
@pytest.fixture
111+
def test_item(expect_standard_dictionaries, use_complex_item):
112+
"""Get a single test item in the appropriate format for the client."""
113+
if expect_standard_dictionaries:
114+
if use_complex_item:
115+
return complex_item_dict
116+
return simple_item_dict
117+
if use_complex_item:
118+
return complex_item_ddb
119+
return simple_item_ddb
120+
121+
122+
@pytest.fixture
123+
def paginate_query_request(expect_standard_dictionaries, test_key):
124+
if expect_standard_dictionaries:
125+
return {**basic_query_paginator_request(test_key), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME}
126+
return basic_query_paginator_request(test_key)
127+
128+
129+
@pytest.fixture
130+
def put_item_request(expect_standard_dictionaries, test_item):
131+
if expect_standard_dictionaries:
132+
# Client requests with `expect_standard_dictionaries=True` use dict-formatted requests
133+
# with an added "TableName" key.
134+
return {**basic_put_item_request_dict(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME}
135+
return basic_put_item_request_ddb(test_item)
136+
137+
138+
def test_GIVEN_query_paginator_WHEN_paginate_THEN_returns_expected_items(
139+
client, query_paginator, paginate_query_request, put_item_request, test_item
140+
):
141+
# Given: item in table
142+
client.put_item(**put_item_request)
143+
# Given: Query paginator
144+
# When: Paginate
145+
response = query_paginator.paginate(**paginate_query_request)
146+
# Then: Returns encrypted items
147+
items = []
148+
for page in response:
149+
if "Items" in page:
150+
for item in page["Items"]:
151+
items.append(item)
152+
assert len(items) == 1
153+
# DynamoDB JSON uses lists to represent sets, so strict equality can fail.
154+
# Sort lists to ensure consistent ordering when comparing expected and actual items.
155+
expected_item = sort_dynamodb_json_lists(test_item)
156+
actual_item = sort_dynamodb_json_lists(items[0])
157+
# Then: Items are equal
158+
assert expected_item == actual_item
159+
160+
161+
@pytest.fixture
162+
def paginate_scan_request(expect_standard_dictionaries, test_item):
163+
if expect_standard_dictionaries:
164+
request = {**basic_scan_paginator_request(test_item), "TableName": INTEG_TEST_DEFAULT_DYNAMODB_TABLE_NAME}
165+
else:
166+
request = basic_scan_paginator_request(test_item)
167+
return request
168+
169+
170+
def test_GIVEN_scan_paginator_WHEN_paginate_THEN_returns_expected_items(
171+
client, scan_paginator, paginate_scan_request, put_item_request, test_item
172+
):
173+
# Given: item in table
174+
client.put_item(**put_item_request)
175+
# Given: Scan paginator
176+
# When: Paginate
177+
response = scan_paginator.paginate(**paginate_scan_request)
178+
# Then: Returns encrypted items
179+
items = []
180+
for page in response:
181+
if "Items" in page:
182+
for item in page["Items"]:
183+
items.append(item)
184+
assert len(items) == 1
185+
# DynamoDB JSON uses lists to represent sets, so strict equality can fail.
186+
# Sort lists to ensure consistent ordering when comparing expected and actual items.
187+
expected_item = sort_dynamodb_json_lists(test_item)
188+
actual_item = sort_dynamodb_json_lists(items[0])
189+
# Then: Items are equal
190+
assert expected_item == actual_item

0 commit comments

Comments
 (0)