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.
- {clear_skies_aws-1.8.5.dist-info → clear_skies_aws-1.9.1.dist-info}/METADATA +1 -1
- {clear_skies_aws-1.8.5.dist-info → clear_skies_aws-1.9.1.dist-info}/RECORD +8 -7
- clearskies_aws/actions/ses.py +15 -4
- clearskies_aws/actions/ses_test.py +39 -0
- clearskies_aws/handlers/__init__.py +1 -0
- clearskies_aws/handlers/secrets_manager_rotation.py +174 -0
- {clear_skies_aws-1.8.5.dist-info → clear_skies_aws-1.9.1.dist-info}/LICENSE +0 -0
- {clear_skies_aws-1.8.5.dist-info → clear_skies_aws-1.9.1.dist-info}/WHEEL +0 -0
|
@@ -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=
|
|
7
|
-
clearskies_aws/actions/ses_test.py,sha256=
|
|
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=
|
|
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.
|
|
61
|
-
clear_skies_aws-1.
|
|
62
|
-
clear_skies_aws-1.
|
|
63
|
-
clear_skies_aws-1.
|
|
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,,
|
clearskies_aws/actions/ses.py
CHANGED
|
@@ -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
|
+
])
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
File without changes
|