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