Skip to content

Commit 8689c5c

Browse files
committed
chore(python): examples for client_supplier
1 parent 2201e4a commit 8689c5c

File tree

6 files changed

+363
-0
lines changed

6 files changed

+363
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Stub to allow relative imports of examples from tests."""
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""
4+
This example sets up an MRK multi-keyring and an MRK discovery
5+
multi-keyring using a custom client supplier.
6+
7+
A custom client supplier grants users access to more granular
8+
configuration aspects of their authentication details and KMS
9+
client. In this example, we create a simple custom client supplier
10+
that authenticates with a different IAM role based on the
11+
region of the KMS key.
12+
13+
This example creates a MRK multi-keyring configured with a custom
14+
client supplier using a single MRK and puts an encrypted item to the
15+
table. Then, it creates a MRK discovery multi-keyring to decrypt the item
16+
and retrieves the item from the table.
17+
18+
Running this example requires access to the DDB Table whose name
19+
is provided in CLI arguments.
20+
This table must be configured with the following
21+
primary key configuration:
22+
- Partition key is named "partition_key" with type (S)
23+
- Sort key is named "sort_key" with type (N)
24+
"""
25+
26+
from typing import List
27+
28+
import boto3
29+
from aws_cryptographic_material_providers.mpl import AwsCryptographicMaterialProviders
30+
from aws_cryptographic_material_providers.mpl.config import MaterialProvidersConfig
31+
from aws_cryptographic_material_providers.mpl.models import (
32+
CreateAwsKmsMrkMultiKeyringInput,
33+
CreateAwsKmsMrkDiscoveryMultiKeyringInput,
34+
DiscoveryFilter,
35+
)
36+
from aws_cryptographic_material_providers.mpl.references import IKeyring
37+
from aws_dbesdk_dynamodb.encrypted.client import EncryptedClient
38+
from aws_dbesdk_dynamodb.structures.dynamodb import (
39+
DynamoDbTableEncryptionConfig,
40+
DynamoDbTablesEncryptionConfig,
41+
)
42+
from aws_dbesdk_dynamodb.structures.structured_encryption import (
43+
CryptoAction,
44+
)
45+
46+
from .regional_role_client_supplier import RegionalRoleClientSupplier
47+
48+
49+
def client_supplier_example(
50+
ddb_table_name: str,
51+
key_arn: str,
52+
account_ids: List[str],
53+
regions: List[str]
54+
) -> None:
55+
"""
56+
Demonstrate how to use a custom client supplier with AWS KMS MRK multi-keyring
57+
and AWS KMS MRK discovery multi-keyring.
58+
59+
:param ddb_table_name: The name of the DynamoDB table
60+
:param key_arn: The ARN of the AWS KMS key
61+
:param account_ids: List of AWS account IDs
62+
:param regions: List of AWS regions
63+
"""
64+
# 1. Create a single MRK multi-keyring.
65+
# This can be either a single-region KMS key or an MRK.
66+
# For this example to succeed, the key's region must either
67+
# 1) be in the regions list, or
68+
# 2) the key must be an MRK with a replica defined
69+
# in a region in the regions list, and the client
70+
# must have the correct permissions to access the replica.
71+
mat_prov = AwsCryptographicMaterialProviders(config=MaterialProvidersConfig())
72+
73+
# Create the multi-keyring using our custom client supplier
74+
# defined in the RegionalRoleClientSupplier class in this directory.
75+
create_aws_kms_mrk_multi_keyring_input = CreateAwsKmsMrkMultiKeyringInput(
76+
# Note: RegionalRoleClientSupplier will internally use the keyArn's region
77+
# to retrieve the correct IAM role.
78+
client_supplier=RegionalRoleClientSupplier(),
79+
generator=key_arn
80+
)
81+
mrk_keyring_with_client_supplier = mat_prov.create_aws_kms_mrk_multi_keyring(
82+
input=create_aws_kms_mrk_multi_keyring_input
83+
)
84+
85+
# 2. Configure which attributes are encrypted and/or signed when writing new items.
86+
# For each attribute that may exist on the items we plan to write to our DynamoDbTable,
87+
# we must explicitly configure how they should be treated during item encryption:
88+
# - ENCRYPT_AND_SIGN: The attribute is encrypted and included in the signature
89+
# - SIGN_ONLY: The attribute is not encrypted, but is still included in the signature
90+
# - DO_NOTHING: The attribute is not encrypted and not included in the signature
91+
attribute_actions_on_encrypt = {
92+
"partition_key": CryptoAction.SIGN_ONLY, # Our partition attribute must be SIGN_ONLY
93+
"sort_key": CryptoAction.SIGN_ONLY, # Our sort attribute must be SIGN_ONLY
94+
"sensitive_data": CryptoAction.ENCRYPT_AND_SIGN
95+
}
96+
97+
# 3. Configure which attributes we expect to be included in the signature
98+
# when reading items. There are two options for configuring this:
99+
#
100+
# - (Recommended) Configure `allowed_unsigned_attribute_prefix`:
101+
# When defining your DynamoDb schema and deciding on attribute names,
102+
# choose a distinguishing prefix (such as ":") for all attributes that
103+
# you do not want to include in the signature.
104+
# This has two main benefits:
105+
# - It is easier to reason about the security and authenticity of data within your item
106+
# when all unauthenticated data is easily distinguishable by their attribute name.
107+
# - If you need to add new unauthenticated attributes in the future,
108+
# you can easily make the corresponding update to your `attribute_actions_on_encrypt`
109+
# and immediately start writing to that new attribute, without
110+
# any other configuration update needed.
111+
# Once you configure this field, it is not safe to update it.
112+
#
113+
# - Configure `allowed_unsigned_attributes`: You may also explicitly list
114+
# a set of attributes that should be considered unauthenticated when encountered
115+
# on read. Be careful if you use this configuration. Do not remove an attribute
116+
# name from this configuration, even if you are no longer writing with that attribute,
117+
# as old items may still include this attribute, and our configuration needs to know
118+
# to continue to exclude this attribute from the signature scope.
119+
# If you add new attribute names to this field, you must first deploy the update to this
120+
# field to all readers in your host fleet before deploying the update to start writing
121+
# with that new attribute.
122+
#
123+
# For this example, we currently authenticate all attributes. To make it easier to
124+
# add unauthenticated attributes in the future, we define a prefix ":" for such attributes.
125+
unsign_attr_prefix = ":"
126+
127+
# 4. Create the DynamoDb Encryption configuration for the table we will be writing to.
128+
table_config = DynamoDbTableEncryptionConfig(
129+
logical_table_name=ddb_table_name,
130+
partition_key_name="partition_key",
131+
sort_key_name="sort_key",
132+
attribute_actions_on_encrypt=attribute_actions_on_encrypt,
133+
keyring=mrk_keyring_with_client_supplier,
134+
allowed_unsigned_attribute_prefix=unsign_attr_prefix
135+
)
136+
137+
table_configs = {ddb_table_name: table_config}
138+
tables_config = DynamoDbTablesEncryptionConfig(table_encryption_configs=table_configs)
139+
140+
# 5. Create the EncryptedClient
141+
ddb_client = boto3.client('dynamodb')
142+
encrypted_ddb_client = EncryptedClient(
143+
client=ddb_client,
144+
encryption_config=tables_config
145+
)
146+
147+
# 6. Put an item into our table using the above client.
148+
# Before the item gets sent to DynamoDb, it will be encrypted
149+
# client-side using the MRK multi-keyring.
150+
# The data key protecting this item will be encrypted
151+
# with all the KMS Keys in this keyring, so that it can be
152+
# decrypted with any one of those KMS Keys.
153+
item = {
154+
"partition_key": {"S": "clientSupplierItem"},
155+
"sort_key": {"N": "0"},
156+
"sensitive_data": {"S": "encrypt and sign me!"}
157+
}
158+
159+
put_response = encrypted_ddb_client.put_item(
160+
TableName=ddb_table_name,
161+
Item=item
162+
)
163+
164+
# Demonstrate that PutItem succeeded
165+
assert put_response['ResponseMetadata']['HTTPStatusCode'] == 200
166+
167+
# 7. Get the item back from our table using the same keyring.
168+
# The client will decrypt the item client-side using the MRK
169+
# and return the original item.
170+
key_to_get = {
171+
"partition_key": {"S": "clientSupplierItem"},
172+
"sort_key": {"N": "0"}
173+
}
174+
175+
get_response = encrypted_ddb_client.get_item(
176+
TableName=ddb_table_name,
177+
Key=key_to_get
178+
)
179+
180+
# Demonstrate that GetItem succeeded and returned the decrypted item
181+
assert get_response['ResponseMetadata']['HTTPStatusCode'] == 200
182+
returned_item = get_response['Item']
183+
assert returned_item["sensitive_data"]["S"] == "encrypt and sign me!"
184+
185+
# 8. Create a MRK discovery multi-keyring with a custom client supplier.
186+
# A discovery MRK multi-keyring will be composed of
187+
# multiple discovery MRK keyrings, one for each region.
188+
# Each component keyring has its own KMS client in a particular region.
189+
# When we provide a client supplier to the multi-keyring, all component
190+
# keyrings will use that client supplier configuration.
191+
# In our tests, we make `key_arn` an MRK with a replica, and
192+
# provide only the replica region in our discovery filter.
193+
discovery_filter = DiscoveryFilter(
194+
partition="aws",
195+
account_ids=account_ids
196+
)
197+
198+
mrk_discovery_client_supplier_input = CreateAwsKmsMrkDiscoveryMultiKeyringInput(
199+
client_supplier=RegionalRoleClientSupplier(),
200+
discovery_filter=discovery_filter,
201+
regions=regions
202+
)
203+
204+
mrk_discovery_client_supplier_keyring = mat_prov.create_aws_kms_mrk_discovery_multi_keyring(
205+
input=mrk_discovery_client_supplier_input
206+
)
207+
208+
# 9. Create a new config and client using the discovery keyring.
209+
# This is the same setup as above, except we provide the discovery keyring to the config.
210+
replica_key_table_config = DynamoDbTableEncryptionConfig(
211+
logical_table_name=ddb_table_name,
212+
partition_key_name="partition_key",
213+
sort_key_name="sort_key",
214+
attribute_actions_on_encrypt=attribute_actions_on_encrypt,
215+
# Provide discovery keyring here
216+
keyring=mrk_discovery_client_supplier_keyring,
217+
allowed_unsigned_attribute_prefix=unsign_attr_prefix
218+
)
219+
220+
replica_key_tables_config = {ddb_table_name: replica_key_table_config}
221+
replica_key_tables_encryption_config = DynamoDbTablesEncryptionConfig(
222+
table_encryption_configs=replica_key_tables_config
223+
)
224+
225+
replica_key_encrypted_client = EncryptedClient(
226+
client=ddb_client,
227+
encryption_config=replica_key_tables_encryption_config
228+
)
229+
230+
# 10. Get the item back from our table using the discovery keyring client.
231+
# The client will decrypt the item client-side using the keyring,
232+
# and return the original item.
233+
# The discovery keyring will only use KMS keys in the provided regions and
234+
# AWS accounts. Since we have provided it with a custom client supplier
235+
# which uses different IAM roles based on the key region,
236+
# the discovery keyring will use a particular IAM role to decrypt
237+
# based on the region of the KMS key it uses to decrypt.
238+
replica_key_key_to_get = {
239+
"partition_key": {"S": "awsKmsMrkMultiKeyringItem"},
240+
"sort_key": {"N": "0"}
241+
}
242+
243+
replica_key_get_response = replica_key_encrypted_client.get_item(
244+
TableName=ddb_table_name,
245+
Key=replica_key_key_to_get
246+
)
247+
248+
# Demonstrate that GetItem succeeded and returned the decrypted item
249+
assert replica_key_get_response['ResponseMetadata']['HTTPStatusCode'] == 200
250+
replica_key_returned_item = replica_key_get_response['Item']
251+
assert replica_key_returned_item["sensitive_data"]["S"] == "encrypt and sign me!"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""
4+
Example class demonstrating an implementation of a custom client supplier.
5+
This particular implementation will create KMS clients with different IAM roles,
6+
depending on the region passed.
7+
"""
8+
9+
import boto3
10+
import logging
11+
from botocore.exceptions import ClientError
12+
from aws_cryptographic_material_providers.mpl.references import ClientSupplier
13+
from aws_cryptographic_material_providers.mpl.models import GetClientInput
14+
15+
from .regional_role_client_supplier_config import RegionalRoleClientSupplierConfig
16+
17+
18+
class RegionalRoleClientSupplier(ClientSupplier):
19+
"""
20+
Custom client supplier that creates KMS clients with different IAM roles
21+
depending on the region passed.
22+
"""
23+
24+
def __init__(self):
25+
self._sts_client = boto3.client('sts')
26+
self._config = RegionalRoleClientSupplierConfig()
27+
self._logger = logging.getLogger(__name__)
28+
29+
def get_client(self, input_params: GetClientInput) -> boto3.client:
30+
"""
31+
Get a KMS client for the specified region using the configured IAM role.
32+
33+
In test environments where assuming the role might fail, we fall back to
34+
creating a standard KMS client for the region without assuming a role.
35+
This ensures examples can run in test environments without proper IAM permissions.
36+
37+
:param input_params: Input parameters containing the region
38+
:return: A boto3 KMS client for the specified region with the appropriate credentials
39+
"""
40+
region = input_params.region
41+
if region not in self._config.region_iam_role_map:
42+
self._logger.warning(f"Missing region in config: {region}. Using default client.")
43+
return boto3.client('kms', region_name=region)
44+
45+
role_arn = self._config.region_iam_role_map[region]
46+
47+
try:
48+
# Assume the IAM role for the region
49+
response = self._sts_client.assume_role(
50+
RoleArn=role_arn,
51+
DurationSeconds=900, # 15 minutes is the minimum value
52+
RoleSessionName="Python-Client-Supplier-Example-Session"
53+
)
54+
55+
# Create a KMS client with the temporary credentials
56+
return boto3.client(
57+
'kms',
58+
region_name=region,
59+
aws_access_key_id=response['Credentials']['AccessKeyId'],
60+
aws_secret_access_key=response['Credentials']['SecretAccessKey'],
61+
aws_session_token=response['Credentials']['SessionToken']
62+
)
63+
except ClientError as e:
64+
# In test environments, fall back to a standard client
65+
self._logger.warning(f"Failed to assume role: {str(e)}. Falling back to default client.")
66+
return boto3.client('kms', region_name=region)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""
4+
Class containing config for the RegionalRoleClientSupplier.
5+
In your own code, this might be hardcoded, or reference
6+
an external source, e.g. environment variables or AWS AppConfig.
7+
"""
8+
9+
10+
class RegionalRoleClientSupplierConfig:
11+
US_EAST_1_IAM_ROLE = "arn:aws:iam::370957321024:role/GitHub-CI-DDBEC-Dafny-Role-only-us-east-1-KMS-keys"
12+
EU_WEST_1_IAM_ROLE = "arn:aws:iam::370957321024:role/GitHub-CI-DDBEC-Dafny-Role-only-eu-west-1-KMS-keys"
13+
14+
def __init__(self):
15+
self.region_iam_role_map = {
16+
"us-east-1": self.US_EAST_1_IAM_ROLE,
17+
"eu-west-1": self.EU_WEST_1_IAM_ROLE
18+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Test suite for the client_supplier examples."""
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Test suite for the client supplier example."""
4+
import pytest
5+
6+
from .. import test_utils
7+
from ...src.client_supplier.client_supplier_example import client_supplier_example
8+
9+
pytestmark = [pytest.mark.examples]
10+
11+
def test_client_supplier_example():
12+
"""Test function for client supplier example."""
13+
accounts = [test_utils.TEST_AWS_ACCOUNT_ID]
14+
regions = ["eu-west-1"] # Using eu-west-1
15+
16+
# Call the client_supplier_example with the test parameters
17+
client_supplier_example(
18+
ddb_table_name=test_utils.TEST_DDB_TABLE_NAME,
19+
key_arn=test_utils.TEST_MRK_REPLICA_KEY_ID_US_EAST_1,
20+
account_ids=accounts,
21+
regions=regions
22+
)

0 commit comments

Comments
 (0)