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