clear-skies-aws 1.8.5__py3-none-any.whl → 1.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clear-skies-aws
3
- Version: 1.8.5
3
+ Version: 1.9.1
4
4
  Summary: clearskies bindings for working in AWS
5
5
  Home-page: https://github.com/cmancone/clearskies-aws
6
6
  License: MIT
@@ -3,8 +3,8 @@ clearskies_aws/actions/__init__.py,sha256=QEKR3TKEp7NWqhfz0g19Dv2v8KTTVewKi_3zeX
3
3
  clearskies_aws/actions/action_aws.py,sha256=hWYnYA0QNegWp2LDMYILs0Dk1TOsFykW5JgSEMwfDE8,3877
4
4
  clearskies_aws/actions/assume_role.py,sha256=tZHKMTQaImj_EAWormFZdIPkR_db7yBwOJxYaUyuGdA,4085
5
5
  clearskies_aws/actions/assume_role_test.py,sha256=MDMGePZLVyDNTjdAlGBDJzBlfMpGIpA3Q1Ur0qEPl1w,2450
6
- clearskies_aws/actions/ses.py,sha256=Lc554msF20ZDUBu43CfdA6bu2-xeOW6d2daOHAn1x5g,6933
7
- clearskies_aws/actions/ses_test.py,sha256=6XydNRXmniViOHkWcRCZNCEuGi3K2tp2w7Fkyg6JEBk,1739
6
+ clearskies_aws/actions/ses.py,sha256=iSo-dtQoreqOCveGpDAKWkbaa0gZx1xTymHYqlvL7hk,7886
7
+ clearskies_aws/actions/ses_test.py,sha256=lyNbqI46Ld_ZUKtWLgSwwfSOFksQ39H4hzPS3eTx7nk,3076
8
8
  clearskies_aws/actions/sns.py,sha256=m9LEoogTGGPr-u8oj39PIiOd9rkoF340nFbWefXJREA,2197
9
9
  clearskies_aws/actions/sns_test.py,sha256=iqlaZOA-0YHPOu52-XqpCIAgAv1JZodDdlm4YYYhj8U,2306
10
10
  clearskies_aws/actions/sqs.py,sha256=UZykBoi83EzzlqaPVdobZ8D_JRYqPFCwrCjWqg0ACzk,2340
@@ -30,7 +30,8 @@ clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py,sha256=q3uqICn
30
30
  clearskies_aws/contexts/wsgi.py,sha256=wmHnDIfcrQN0I547uE2mlHHQtOLOpLfpbKpBJkw5puY,510
31
31
  clearskies_aws/di/__init__.py,sha256=KLq6G-CKR-Vdk9LZe5TijW_U-McJlrp1QuIXqmyAL-A,56
32
32
  clearskies_aws/di/standard_dependencies.py,sha256=WptTwpEjRrPx7gN7VF_pHfW0eJIErjOyR0Q0nk4DXEo,775
33
- clearskies_aws/handlers/__init__.py,sha256=uzChAhrSefZdvCkwiMLyNP7ij5yXPOBYtJ67XX_trzM,51
33
+ clearskies_aws/handlers/__init__.py,sha256=E2x04QGy5dfaDIB5Xu8IRWwgypIrgGEZibijtKWe8jM,112
34
+ clearskies_aws/handlers/secrets_manager_rotation.py,sha256=KV4XWKar4gT0Xu0eM_D75ECzYkjXxjeZtkOopPRG9rc,7471
34
35
  clearskies_aws/handlers/simple_body_routing.py,sha256=FGdeDY8nZIGfCOn0oOHi6EOP1h4xXcBP3W9fH0NuBlc,1449
35
36
  clearskies_aws/input_outputs/__init__.py,sha256=Tf8kWN95nwUbvlVaboYT06ICtSuZd0MPPzwWhO5iQGs,385
36
37
  clearskies_aws/input_outputs/cli_websocket_mock.py,sha256=J3PDYnFxOzqYhmlxWFcobbb4McKpsGduIBq6jdSAx5Y,434
@@ -57,7 +58,7 @@ clearskies_aws/secrets/parameter_store_test.py,sha256=UyqKE4AZYlldj9ww5f0fR15qsV
57
58
  clearskies_aws/secrets/secrets_manager.py,sha256=jlpfAFC23EeSpm50L8B-yrXg4IROQq-M_90zzXDp_ak,3056
58
59
  clearskies_aws/secrets/secrets_manager_test.py,sha256=mlNWtDm1wWS5C8aV0vJAzZVZB82KFR6NGRAPEkLtTyk,786
59
60
  clearskies_aws/web_socket_connection_model.py,sha256=d_Au_Pu7YXBfc7_lbuI7zz4MZ8ZOOwGM0oooppEofcI,1776
60
- clear_skies_aws-1.8.5.dist-info/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
61
- clear_skies_aws-1.8.5.dist-info/METADATA,sha256=GP3Cntgty7nBtpIMrpf1gOZYQO_aT6NU2NzNfMM_BMA,8579
62
- clear_skies_aws-1.8.5.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
63
- clear_skies_aws-1.8.5.dist-info/RECORD,,
61
+ clear_skies_aws-1.9.1.dist-info/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
62
+ clear_skies_aws-1.9.1.dist-info/METADATA,sha256=TabZogVH3Y9e30ZE81jx_cqvojmOVahuYa5eQ9jzNHM,8579
63
+ clear_skies_aws-1.9.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
64
+ clear_skies_aws-1.9.1.dist-info/RECORD,,
@@ -22,9 +22,9 @@ class SES(ActionAws):
22
22
  def configure(
23
23
  self,
24
24
  sender,
25
- to: Optional[Union[list, str]] = None,
26
- cc: Optional[Union[list, str]] = None,
27
- bcc: Optional[Union[list, str]] = None,
25
+ to: Optional[Union[list, str, Callable]] = None,
26
+ cc: Optional[Union[list, str, Callable]] = None,
27
+ bcc: Optional[Union[list, str, Callable]] = None,
28
28
  subject: Optional[str] = None,
29
29
  message: Optional[str] = None,
30
30
  subject_template: Optional[str] = None,
@@ -48,7 +48,7 @@ class SES(ActionAws):
48
48
  destination_values = locals()[key]
49
49
  if not destination_values:
50
50
  continue
51
- if type(destination_values) == str:
51
+ if type(destination_values) == str or callable(destination_values):
52
52
  self.destinations[key] = [destination_values]
53
53
  else:
54
54
  self.destinations[key] = destination_values
@@ -136,6 +136,17 @@ class SES(ActionAws):
136
136
  resolved = []
137
137
  destinations = self.destinations[name]
138
138
  for destination in destinations:
139
+ if callable(destination):
140
+ more = self.di.call_function(destination, model=model)
141
+ if not isinstance(more, list):
142
+ more = [more]
143
+ for entry in more:
144
+ if not isinstance(entry, str):
145
+ raise ValueError(f"I invoked a callable to fetch the '{name}' addresses for model '{model.__class__.__name__}' but it returned something other than a string. Callables must return a valid email address or a list of email addresses.")
146
+ if "@" not in entry:
147
+ raise ValueError(f"I invoked a callable to fetch the '{name}' addresses for model '{model.__class__.__name__}' but it returned a non-email address. Callables must return a valid email address or a list of email addresses.")
148
+ resolved.extend(more)
149
+ continue
139
150
  if "@" in destination:
140
151
  resolved.append(destination)
141
152
  continue
@@ -48,3 +48,42 @@ class SESTest(unittest.TestCase):
48
48
  Source='test@example.com'
49
49
  ),
50
50
  ])
51
+
52
+ def test_send_callable(self):
53
+ ses = SES(self.environment, self.boto3, self.di)
54
+ ses.configure(
55
+ 'test@example.com',
56
+ to=lambda model: 'jane@example.com',
57
+ bcc=lambda model: ['bob@example.com', 'greg@example.com'],
58
+ subject='welcome!',
59
+ message_template='hi {{ model.id }}!'
60
+ )
61
+ model = MagicMock()
62
+ model.id = 'asdf'
63
+ ses(model)
64
+ self.ses.send_email.assert_has_calls([
65
+ call(
66
+ Destination={
67
+ 'ToAddresses': ['jane@example.com'],
68
+ 'CcAddresses': [],
69
+ 'BccAddresses': ['bob@example.com', 'greg@example.com']
70
+ },
71
+ Message={
72
+ 'Body': {
73
+ 'Html': {
74
+ 'Charset': 'utf-8',
75
+ 'Data': 'hi asdf!'
76
+ },
77
+ 'Text': {
78
+ 'Charset': 'utf-8',
79
+ 'Data': 'hi asdf!'
80
+ }
81
+ },
82
+ 'Subject': {
83
+ 'Charset': 'utf-8',
84
+ 'Data': 'welcome!'
85
+ }
86
+ },
87
+ Source='test@example.com'
88
+ ),
89
+ ])
@@ -1 +1,2 @@
1
1
  from .simple_body_routing import SimpleBodyRouting
2
+ from .secrets_manager_rotation import SecretsManagerRotation
@@ -0,0 +1,174 @@
1
+ import json
2
+
3
+ import clearskies
4
+ from clearskies.handlers.exceptions import ClientError
5
+ from clearskies.handlers.base import Base
6
+ import botocore
7
+
8
+ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
9
+ _steps = ["createSecret", "setSecret", "testSecret", "finishSecret"]
10
+
11
+ current = "AWSCURRENT"
12
+ pending = "AWSPENDING"
13
+
14
+ _configuration_defaults = {
15
+ "createSecret": None,
16
+ "setSecret": None,
17
+ "testSecret": None,
18
+ "finishSecret": None,
19
+ "schema": [],
20
+ }
21
+
22
+ def __init__(self, boto3, di):
23
+ super().__init__(di)
24
+ self.boto3 = boto3
25
+
26
+ def _check_configuration(self, configuration):
27
+ super()._check_configuration(configuration)
28
+ class_name = self.__class__.__name__
29
+ if not configuration.get("createSecret"):
30
+ raise KeyError(f"Missing required configuration 'createSecret' for handler {class_name}")
31
+
32
+ for config_name in self._steps:
33
+ config = configuration.get(config_name)
34
+ if config is None:
35
+ continue
36
+ if not callable(config):
37
+ raise ValueError(f"Misconfiguration for handler {class_name}: configuration '{config_name}' is not callable")
38
+
39
+ if configuration.get("schema") is not None:
40
+ self._check_schema(configuration["schema"], None, f"Misconfiguration for handler {class_name}")
41
+
42
+ def _finalize_configuration(self, configuration):
43
+ if configuration.get('schema'):
44
+ configuration['schema'] = self._schema_to_columns(configuration['schema'])
45
+ return super()._finalize_configuration(configuration)
46
+
47
+ def handle(self, input_output):
48
+ request_data = input_output.json_body()
49
+
50
+ arn = request_data.get('SecretId')
51
+ request_token = request_data.get('ClientRequestToken')
52
+ step = request_data.get('Step')
53
+ secretsmanager = self.boto3.client('secretsmanager')
54
+ metadata = secretsmanager.describe_secret(SecretId=arn)
55
+
56
+ self._validate_secret_and_request(step, arn, metadata, request_token)
57
+
58
+ current_secret_data = {}
59
+ pending_secret_data = {}
60
+
61
+ current_secret = secretsmanager.get_secret_value(SecretId=arn, VersionStage=self.current)
62
+ current_secret_data = json.loads(current_secret['SecretString'])
63
+
64
+ # validate the current secret
65
+ secret_errors = {
66
+ **self._extra_column_errors(current_secret_data),
67
+ **self._find_input_errors(current_secret_data),
68
+ }
69
+ if secret_errors:
70
+ raise ValueError(f"The current secret did not match the configured schema: {secret_errors}")
71
+
72
+ # check for a pending secret. Note that this is not always available. In the event that we are retrying a failed
73
+ # rotation it will already be set, in which case we need to skip the createSecret step.
74
+ try:
75
+ pending_secret = secretsmanager.get_secret_value(SecretId=arn, VersionId=request_token, VersionStage=self.pending)
76
+ pending_secret_data = json.loads(pending_secret['SecretString'])
77
+ except botocore.exceptions.ClientError as error:
78
+ if error.response['Error']['Code'] == 'ResourceNotFoundException':
79
+ pending_secret_data = None
80
+ else:
81
+ raise error
82
+
83
+ # we can't call the createSecret step if we already have a pending secret or this will generate an error from AWS.
84
+ if step == "createSecret" and pending_secret_data is not None:
85
+ return
86
+
87
+ # call the appropriate step and pass along *everything*.
88
+ getattr(self, step)(
89
+ current_secret_data=current_secret_data,
90
+ pending_secret_data=pending_secret_data,
91
+ secretsmanager=secretsmanager,
92
+ metadata=metadata,
93
+ request_token=request_token,
94
+ arn=arn,
95
+ )
96
+
97
+ def _validate_secret_and_request(self, step, arn, metadata, request_token):
98
+ """ This function does some basic checks suggested by AWS of both the request and the secret to make sure everything is on the up-and-up. """
99
+ if step not in self._steps:
100
+ raise ClientError(f"Invalid step: {step}")
101
+
102
+ if not metadata.get('RotationEnabled'):
103
+ raise ValueError("Secret %s is not enabled for rotation" % arn)
104
+
105
+ versions = metadata["VersionIdsToStages"]
106
+ prefix = f"Rotation config error for version '{request_token}' of secret '{arn}': "
107
+ if request_token not in versions:
108
+ raise ValueError(f"{prefix} we don't have a stage for rotation")
109
+ if self.current in versions[request_token]:
110
+ raise ValueError(f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice.")
111
+ elif self.pending not in versions[request_token]:
112
+ raise ValueError(f"{prefix} it hasn't been set to pending yet, which makes no sense!")
113
+
114
+ def createSecret(self, **kwargs):
115
+ new_secret_data = self._di.call_function(self._configuration["createSecret"], **kwargs)
116
+ if new_secret_data is None:
117
+ raise ValueError(f"I called the configured createSecret function but it didn't return anything. It has to return the new secret data.")
118
+ if not isinstance(new_secret_data, dict):
119
+ raise ValueError(f"I called the configured createSecret function but it didn't return a dictionary. The createSecret function must return a dictionary.")
120
+
121
+ secret_errors = {
122
+ **self._extra_column_errors(new_secret_data),
123
+ **self._find_input_errors(new_secret_data),
124
+ }
125
+ if secret_errors:
126
+ raise ValueError(f"The secret data returned by the call to createSecret did not match the configured schema: {secret_errors}")
127
+
128
+ # if we get this far we can store the new data
129
+ secretsmanager = kwargs["secretsmanager"]
130
+ request_token = kwargs["request_token"]
131
+ arn = kwargs["arn"]
132
+ secretsmanager.put_secret_value(
133
+ SecretId=arn,
134
+ SecretString=json.dumps(new_secret_data),
135
+ ClientRequestToken=request_token,
136
+ VersionStages=[self.pending],
137
+ )
138
+
139
+ def setSecret(self, **kwargs):
140
+ if not self._configuration.get("setSecret"):
141
+ return
142
+ self._di.call_function(self._configuration["setSecret"], **kwargs)
143
+
144
+ def testSecret(self, **kwargs):
145
+ if not self._configuration.get("testSecret"):
146
+ return
147
+ self._di.call_function(self._configuration["testSecret"], **kwargs)
148
+
149
+ def finishSecret(self, **kwargs):
150
+ if self._configuration.get("finishSecret"):
151
+ self._di.call_function(self._configuration["finishSecret"], **kwargs)
152
+
153
+ secretsmanager = kwargs["secretsmanager"]
154
+ request_token = kwargs["request_token"]
155
+ arn = kwargs["arn"]
156
+ metadata = kwargs["metadata"]
157
+ current_version = None
158
+ for version in metadata["VersionIdsToStages"]:
159
+ if self.current not in metadata["VersionIdsToStages"][version]:
160
+ continue
161
+
162
+ if version == request_token:
163
+ return
164
+
165
+ current_version = version
166
+ break
167
+
168
+ # finish the rotation by taking the new version and making it current.
169
+ secretsmanager.update_secret_version_stage(
170
+ SecretId=arn,
171
+ VersionStage=self.current,
172
+ MoveToVersionId=request_token,
173
+ RemoveFromVersionId=current_version
174
+ )