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