1#!/usr/local/bin/python3.8 2 3# Copyright: (c) 2018, REY Remi 4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 6from __future__ import absolute_import, division, print_function 7__metaclass__ = type 8 9 10DOCUMENTATION = r''' 11--- 12module: aws_secret 13version_added: 1.0.0 14short_description: Manage secrets stored in AWS Secrets Manager. 15description: 16 - Create, update, and delete secrets stored in AWS Secrets Manager. 17author: "REY Remi (@rrey)" 18requirements: [ 'botocore>=1.10.0', 'boto3' ] 19options: 20 name: 21 description: 22 - Friendly name for the secret you are creating. 23 required: true 24 type: str 25 state: 26 description: 27 - Whether the secret should be exist or not. 28 default: 'present' 29 choices: ['present', 'absent'] 30 type: str 31 recovery_window: 32 description: 33 - Only used if state is absent. 34 - Specifies the number of days that Secrets Manager waits before it can delete the secret. 35 - If set to 0, the deletion is forced without recovery. 36 default: 30 37 type: int 38 description: 39 description: 40 - Specifies a user-provided description of the secret. 41 type: str 42 kms_key_id: 43 description: 44 - Specifies the ARN or alias of the AWS KMS customer master key (CMK) to be 45 used to encrypt the `secret_string` or `secret_binary` values in the versions stored in this secret. 46 type: str 47 secret_type: 48 description: 49 - Specifies the type of data that you want to encrypt. 50 choices: ['binary', 'string'] 51 default: 'string' 52 type: str 53 secret: 54 description: 55 - Specifies string or binary data that you want to encrypt and store in the new version of the secret. 56 default: "" 57 type: str 58 tags: 59 description: 60 - Specifies a list of user-defined tags that are attached to the secret. 61 type: dict 62 rotation_lambda: 63 description: 64 - Specifies the ARN of the Lambda function that can rotate the secret. 65 type: str 66 rotation_interval: 67 description: 68 - Specifies the number of days between automatic scheduled rotations of the secret. 69 default: 30 70 type: int 71extends_documentation_fragment: 72- amazon.aws.ec2 73- amazon.aws.aws 74 75''' 76 77 78EXAMPLES = r''' 79- name: Add string to AWS Secrets Manager 80 community.aws.aws_secret: 81 name: 'test_secret_string' 82 state: present 83 secret_type: 'string' 84 secret: "{{ super_secret_string }}" 85 86- name: remove string from AWS Secrets Manager 87 community.aws.aws_secret: 88 name: 'test_secret_string' 89 state: absent 90 secret_type: 'string' 91 secret: "{{ super_secret_string }}" 92''' 93 94 95RETURN = r''' 96secret: 97 description: The secret information 98 returned: always 99 type: complex 100 contains: 101 arn: 102 description: The ARN of the secret 103 returned: always 104 type: str 105 sample: arn:aws:secretsmanager:eu-west-1:xxxxxxxxxx:secret:xxxxxxxxxxx 106 last_accessed_date: 107 description: The date the secret was last accessed 108 returned: always 109 type: str 110 sample: '2018-11-20T01:00:00+01:00' 111 last_changed_date: 112 description: The date the secret was last modified. 113 returned: always 114 type: str 115 sample: '2018-11-20T12:16:38.433000+01:00' 116 name: 117 description: The secret name. 118 returned: always 119 type: str 120 sample: my_secret 121 rotation_enabled: 122 description: The secret rotation status. 123 returned: always 124 type: bool 125 sample: false 126 version_ids_to_stages: 127 description: Provide the secret version ids and the associated secret stage. 128 returned: always 129 type: dict 130 sample: { "dc1ed59b-6d8e-4450-8b41-536dfe4600a9": [ "AWSCURRENT" ] } 131''' 132 133from ansible.module_utils._text import to_bytes 134from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule 135from ansible_collections.amazon.aws.plugins.module_utils.ec2 import snake_dict_to_camel_dict, camel_dict_to_snake_dict 136from ansible_collections.amazon.aws.plugins.module_utils.ec2 import boto3_tag_list_to_ansible_dict, compare_aws_tags, ansible_dict_to_boto3_tag_list 137 138try: 139 from botocore.exceptions import BotoCoreError, ClientError 140except ImportError: 141 pass # handled by AnsibleAWSModule 142 143 144class Secret(object): 145 """An object representation of the Secret described by the self.module args""" 146 def __init__(self, name, secret_type, secret, description="", kms_key_id=None, 147 tags=None, lambda_arn=None, rotation_interval=None): 148 self.name = name 149 self.description = description 150 self.kms_key_id = kms_key_id 151 if secret_type == "binary": 152 self.secret_type = "SecretBinary" 153 else: 154 self.secret_type = "SecretString" 155 self.secret = secret 156 self.tags = tags or {} 157 self.rotation_enabled = False 158 if lambda_arn: 159 self.rotation_enabled = True 160 self.rotation_lambda_arn = lambda_arn 161 self.rotation_rules = {"AutomaticallyAfterDays": int(rotation_interval)} 162 163 @property 164 def create_args(self): 165 args = { 166 "Name": self.name 167 } 168 if self.description: 169 args["Description"] = self.description 170 if self.kms_key_id: 171 args["KmsKeyId"] = self.kms_key_id 172 if self.tags: 173 args["Tags"] = ansible_dict_to_boto3_tag_list(self.tags) 174 args[self.secret_type] = self.secret 175 return args 176 177 @property 178 def update_args(self): 179 args = { 180 "SecretId": self.name 181 } 182 if self.description: 183 args["Description"] = self.description 184 if self.kms_key_id: 185 args["KmsKeyId"] = self.kms_key_id 186 args[self.secret_type] = self.secret 187 return args 188 189 @property 190 def boto3_tags(self): 191 return ansible_dict_to_boto3_tag_list(self.Tags) 192 193 def as_dict(self): 194 result = self.__dict__ 195 result.pop("tags") 196 return snake_dict_to_camel_dict(result) 197 198 199class SecretsManagerInterface(object): 200 """An interface with SecretsManager""" 201 202 def __init__(self, module): 203 self.module = module 204 self.client = self.module.client('secretsmanager') 205 206 def get_secret(self, name): 207 try: 208 secret = self.client.describe_secret(SecretId=name) 209 except self.client.exceptions.ResourceNotFoundException: 210 secret = None 211 except Exception as e: 212 self.module.fail_json_aws(e, msg="Failed to describe secret") 213 return secret 214 215 def create_secret(self, secret): 216 if self.module.check_mode: 217 self.module.exit_json(changed=True) 218 try: 219 created_secret = self.client.create_secret(**secret.create_args) 220 except (BotoCoreError, ClientError) as e: 221 self.module.fail_json_aws(e, msg="Failed to create secret") 222 223 if secret.rotation_enabled: 224 response = self.update_rotation(secret) 225 created_secret["VersionId"] = response.get("VersionId") 226 return created_secret 227 228 def update_secret(self, secret): 229 if self.module.check_mode: 230 self.module.exit_json(changed=True) 231 232 try: 233 response = self.client.update_secret(**secret.update_args) 234 except (BotoCoreError, ClientError) as e: 235 self.module.fail_json_aws(e, msg="Failed to update secret") 236 return response 237 238 def restore_secret(self, name): 239 if self.module.check_mode: 240 self.module.exit_json(changed=True) 241 try: 242 response = self.client.restore_secret(SecretId=name) 243 except (BotoCoreError, ClientError) as e: 244 self.module.fail_json_aws(e, msg="Failed to restore secret") 245 return response 246 247 def delete_secret(self, name, recovery_window): 248 if self.module.check_mode: 249 self.module.exit_json(changed=True) 250 try: 251 if recovery_window == 0: 252 response = self.client.delete_secret(SecretId=name, ForceDeleteWithoutRecovery=True) 253 else: 254 response = self.client.delete_secret(SecretId=name, RecoveryWindowInDays=recovery_window) 255 except (BotoCoreError, ClientError) as e: 256 self.module.fail_json_aws(e, msg="Failed to delete secret") 257 return response 258 259 def update_rotation(self, secret): 260 if secret.rotation_enabled: 261 try: 262 response = self.client.rotate_secret( 263 SecretId=secret.name, 264 RotationLambdaARN=secret.rotation_lambda_arn, 265 RotationRules=secret.rotation_rules) 266 except (BotoCoreError, ClientError) as e: 267 self.module.fail_json_aws(e, msg="Failed to rotate secret secret") 268 else: 269 try: 270 response = self.client.cancel_rotate_secret(SecretId=secret.name) 271 except (BotoCoreError, ClientError) as e: 272 self.module.fail_json_aws(e, msg="Failed to cancel rotation") 273 return response 274 275 def tag_secret(self, secret_name, tags): 276 try: 277 self.client.tag_resource(SecretId=secret_name, Tags=tags) 278 except (BotoCoreError, ClientError) as e: 279 self.module.fail_json_aws(e, msg="Failed to add tag(s) to secret") 280 281 def untag_secret(self, secret_name, tag_keys): 282 try: 283 self.client.untag_resource(SecretId=secret_name, TagKeys=tag_keys) 284 except (BotoCoreError, ClientError) as e: 285 self.module.fail_json_aws(e, msg="Failed to remove tag(s) from secret") 286 287 def secrets_match(self, desired_secret, current_secret): 288 """Compare secrets except tags and rotation 289 290 Args: 291 desired_secret: camel dict representation of the desired secret state. 292 current_secret: secret reference as returned by the secretsmanager api. 293 294 Returns: bool 295 """ 296 if desired_secret.description != current_secret.get("Description", ""): 297 return False 298 if desired_secret.kms_key_id != current_secret.get("KmsKeyId"): 299 return False 300 current_secret_value = self.client.get_secret_value(SecretId=current_secret.get("Name")) 301 if desired_secret.secret_type == 'SecretBinary': 302 desired_value = to_bytes(desired_secret.secret) 303 else: 304 desired_value = desired_secret.secret 305 if desired_value != current_secret_value.get(desired_secret.secret_type): 306 return False 307 return True 308 309 310def rotation_match(desired_secret, current_secret): 311 """Compare secrets rotation configuration 312 313 Args: 314 desired_secret: camel dict representation of the desired secret state. 315 current_secret: secret reference as returned by the secretsmanager api. 316 317 Returns: bool 318 """ 319 if desired_secret.rotation_enabled != current_secret.get("RotationEnabled", False): 320 return False 321 if desired_secret.rotation_enabled: 322 if desired_secret.rotation_lambda_arn != current_secret.get("RotationLambdaARN"): 323 return False 324 if desired_secret.rotation_rules != current_secret.get("RotationRules"): 325 return False 326 return True 327 328 329def main(): 330 module = AnsibleAWSModule( 331 argument_spec={ 332 'name': dict(required=True), 333 'state': dict(choices=['present', 'absent'], default='present'), 334 'description': dict(default=""), 335 'kms_key_id': dict(), 336 'secret_type': dict(choices=['binary', 'string'], default="string"), 337 'secret': dict(default="", no_log=True), 338 'tags': dict(type='dict', default={}), 339 'rotation_lambda': dict(), 340 'rotation_interval': dict(type='int', default=30), 341 'recovery_window': dict(type='int', default=30), 342 }, 343 supports_check_mode=True, 344 ) 345 346 changed = False 347 state = module.params.get('state') 348 secrets_mgr = SecretsManagerInterface(module) 349 recovery_window = module.params.get('recovery_window') 350 secret = Secret( 351 module.params.get('name'), 352 module.params.get('secret_type'), 353 module.params.get('secret'), 354 description=module.params.get('description'), 355 kms_key_id=module.params.get('kms_key_id'), 356 tags=module.params.get('tags'), 357 lambda_arn=module.params.get('rotation_lambda'), 358 rotation_interval=module.params.get('rotation_interval') 359 ) 360 361 current_secret = secrets_mgr.get_secret(secret.name) 362 363 if state == 'absent': 364 if current_secret: 365 if not current_secret.get("DeletedDate"): 366 result = camel_dict_to_snake_dict(secrets_mgr.delete_secret(secret.name, recovery_window=recovery_window)) 367 changed = True 368 elif current_secret.get("DeletedDate") and recovery_window == 0: 369 result = camel_dict_to_snake_dict(secrets_mgr.delete_secret(secret.name, recovery_window=recovery_window)) 370 changed = True 371 else: 372 result = "secret does not exist" 373 if state == 'present': 374 if current_secret is None: 375 result = secrets_mgr.create_secret(secret) 376 changed = True 377 else: 378 if current_secret.get("DeletedDate"): 379 secrets_mgr.restore_secret(secret.name) 380 changed = True 381 if not secrets_mgr.secrets_match(secret, current_secret): 382 result = secrets_mgr.update_secret(secret) 383 changed = True 384 if not rotation_match(secret, current_secret): 385 result = secrets_mgr.update_rotation(secret) 386 changed = True 387 current_tags = boto3_tag_list_to_ansible_dict(current_secret.get('Tags', [])) 388 tags_to_add, tags_to_remove = compare_aws_tags(current_tags, secret.tags) 389 if tags_to_add: 390 secrets_mgr.tag_secret(secret.name, ansible_dict_to_boto3_tag_list(tags_to_add)) 391 changed = True 392 if tags_to_remove: 393 secrets_mgr.untag_secret(secret.name, tags_to_remove) 394 changed = True 395 result = camel_dict_to_snake_dict(secrets_mgr.get_secret(secret.name)) 396 result.pop("response_metadata") 397 module.exit_json(changed=changed, secret=result) 398 399 400if __name__ == '__main__': 401 main() 402