1#!/usr/local/bin/python3.8
2# Copyright: Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import absolute_import, division, print_function
6__metaclass__ = type
7
8
9DOCUMENTATION = '''
10---
11module: cloudtrail
12version_added: 1.0.0
13short_description: manage CloudTrail create, delete, update
14description:
15  - Creates, deletes, or updates CloudTrail configuration. Ensures logging is also enabled.
16author:
17    - Ansible Core Team
18    - Ted Timmons (@tedder)
19    - Daniel Shepherd (@shepdelacreme)
20requirements:
21  - boto3
22  - botocore
23options:
24  state:
25    description:
26      - Add or remove CloudTrail configuration.
27      - 'The following states have been preserved for backwards compatibility: I(state=enabled) and I(state=disabled).'
28      - I(state=enabled) is equivalet to I(state=present).
29      - I(state=disabled) is equivalet to I(state=absent).
30    type: str
31    choices: ['present', 'absent', 'enabled', 'disabled']
32    default: present
33  name:
34    description:
35      - Name for the CloudTrail.
36      - Names are unique per-region unless the CloudTrail is a multi-region trail, in which case it is unique per-account.
37    type: str
38    default: default
39  enable_logging:
40    description:
41      - Start or stop the CloudTrail logging. If stopped the trail will be paused and will not record events or deliver log files.
42    default: true
43    type: bool
44  s3_bucket_name:
45    description:
46      - An existing S3 bucket where CloudTrail will deliver log files.
47      - This bucket should exist and have the proper policy.
48      - See U(https://docs.aws.amazon.com/awscloudtrail/latest/userguide/aggregating_logs_regions_bucket_policy.html).
49      - Required when I(state=present).
50    type: str
51  s3_key_prefix:
52    description:
53      - S3 Key prefix for delivered log files. A trailing slash is not necessary and will be removed.
54    type: str
55  is_multi_region_trail:
56    description:
57      - Specify whether the trail belongs only to one region or exists in all regions.
58    default: false
59    type: bool
60  enable_log_file_validation:
61    description:
62      - Specifies whether log file integrity validation is enabled.
63      - CloudTrail will create a hash for every log file delivered and produce a signed digest file that can be used to ensure log files have not been tampered.
64    type: bool
65    aliases: [ "log_file_validation_enabled" ]
66  include_global_events:
67    description:
68      - Record API calls from global services such as IAM and STS.
69    default: true
70    type: bool
71    aliases: [ "include_global_service_events" ]
72  sns_topic_name:
73    description:
74      - SNS Topic name to send notifications to when a log file is delivered.
75    type: str
76  cloudwatch_logs_role_arn:
77    description:
78      - Specifies a full ARN for an IAM role that assigns the proper permissions for CloudTrail to create and write to the log group.
79      - See U(https://docs.aws.amazon.com/awscloudtrail/latest/userguide/send-cloudtrail-events-to-cloudwatch-logs.html).
80      - Required when C(cloudwatch_logs_log_group_arn).
81    type: str
82  cloudwatch_logs_log_group_arn:
83    description:
84      - A full ARN specifying a valid CloudWatch log group to which CloudTrail logs will be delivered. The log group should already exist.
85      - See U(https://docs.aws.amazon.com/awscloudtrail/latest/userguide/send-cloudtrail-events-to-cloudwatch-logs.html).
86      - Required when C(cloudwatch_logs_role_arn).
87    type: str
88  kms_key_id:
89    description:
90      - Specifies the KMS key ID to use to encrypt the logs delivered by CloudTrail. This also has the effect of enabling log file encryption.
91      - The value can be an alias name prefixed by "alias/", a fully specified ARN to an alias, a fully specified ARN to a key, or a globally unique identifier.
92      - See U(https://docs.aws.amazon.com/awscloudtrail/latest/userguide/encrypting-cloudtrail-log-files-with-aws-kms.html).
93    type: str
94  tags:
95    description:
96      - A hash/dictionary of tags to be applied to the CloudTrail resource.
97      - Remove completely or specify an empty dictionary to remove all tags.
98    default: {}
99    type: dict
100
101extends_documentation_fragment:
102- amazon.aws.aws
103- amazon.aws.ec2
104
105'''
106
107EXAMPLES = '''
108- name: create single region cloudtrail
109  community.aws.cloudtrail:
110    state: present
111    name: default
112    s3_bucket_name: mylogbucket
113    s3_key_prefix: cloudtrail
114    region: us-east-1
115
116- name: create multi-region trail with validation and tags
117  community.aws.cloudtrail:
118    state: present
119    name: default
120    s3_bucket_name: mylogbucket
121    region: us-east-1
122    is_multi_region_trail: true
123    enable_log_file_validation: true
124    cloudwatch_logs_role_arn: "arn:aws:iam::123456789012:role/CloudTrail_CloudWatchLogs_Role"
125    cloudwatch_logs_log_group_arn: "arn:aws:logs:us-east-1:123456789012:log-group:CloudTrail/DefaultLogGroup:*"
126    kms_key_id: "alias/MyAliasName"
127    tags:
128      environment: dev
129      Name: default
130
131- name: show another valid kms_key_id
132  community.aws.cloudtrail:
133    state: present
134    name: default
135    s3_bucket_name: mylogbucket
136    kms_key_id: "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
137    # simply "12345678-1234-1234-1234-123456789012" would be valid too.
138
139- name: pause logging the trail we just created
140  community.aws.cloudtrail:
141    state: present
142    name: default
143    enable_logging: false
144    s3_bucket_name: mylogbucket
145    region: us-east-1
146    is_multi_region_trail: true
147    enable_log_file_validation: true
148    tags:
149      environment: dev
150      Name: default
151
152- name: delete a trail
153  community.aws.cloudtrail:
154    state: absent
155    name: default
156'''
157
158RETURN = '''
159exists:
160    description: whether the resource exists
161    returned: always
162    type: bool
163    sample: true
164trail:
165    description: CloudTrail resource details
166    returned: always
167    type: complex
168    sample: hash/dictionary of values
169    contains:
170        trail_arn:
171            description: Full ARN of the CloudTrail resource
172            returned: success
173            type: str
174            sample: arn:aws:cloudtrail:us-east-1:123456789012:trail/default
175        name:
176            description: Name of the CloudTrail resource
177            returned: success
178            type: str
179            sample: default
180        is_logging:
181            description: Whether logging is turned on or paused for the Trail
182            returned: success
183            type: bool
184            sample: True
185        s3_bucket_name:
186            description: S3 bucket name where log files are delivered
187            returned: success
188            type: str
189            sample: myBucket
190        s3_key_prefix:
191            description: Key prefix in bucket where log files are delivered (if any)
192            returned: success when present
193            type: str
194            sample: myKeyPrefix
195        log_file_validation_enabled:
196            description: Whether log file validation is enabled on the trail
197            returned: success
198            type: bool
199            sample: true
200        include_global_service_events:
201            description: Whether global services (IAM, STS) are logged with this trail
202            returned: success
203            type: bool
204            sample: true
205        is_multi_region_trail:
206            description: Whether the trail applies to all regions or just one
207            returned: success
208            type: bool
209            sample: true
210        has_custom_event_selectors:
211            description: Whether any custom event selectors are used for this trail.
212            returned: success
213            type: bool
214            sample: False
215        home_region:
216            description: The home region where the trail was originally created and must be edited.
217            returned: success
218            type: str
219            sample: us-east-1
220        sns_topic_name:
221            description: The SNS topic name where log delivery notifications are sent.
222            returned: success when present
223            type: str
224            sample: myTopic
225        sns_topic_arn:
226            description: Full ARN of the SNS topic where log delivery notifications are sent.
227            returned: success when present
228            type: str
229            sample: arn:aws:sns:us-east-1:123456789012:topic/myTopic
230        cloud_watch_logs_log_group_arn:
231            description: Full ARN of the CloudWatch Logs log group where events are delivered.
232            returned: success when present
233            type: str
234            sample: arn:aws:logs:us-east-1:123456789012:log-group:CloudTrail/DefaultLogGroup:*
235        cloud_watch_logs_role_arn:
236            description: Full ARN of the IAM role that CloudTrail assumes to deliver events.
237            returned: success when present
238            type: str
239            sample: arn:aws:iam::123456789012:role/CloudTrail_CloudWatchLogs_Role
240        kms_key_id:
241            description: Full ARN of the KMS Key used to encrypt log files.
242            returned: success when present
243            type: str
244            sample: arn:aws:kms::123456789012:key/12345678-1234-1234-1234-123456789012
245        tags:
246            description: hash/dictionary of tags applied to this resource
247            returned: success
248            type: dict
249            sample: {'environment': 'dev', 'Name': 'default'}
250'''
251
252try:
253    from botocore.exceptions import ClientError, BotoCoreError
254except ImportError:
255    pass  # Handled by AnsibleAWSModule
256
257from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
258from ansible_collections.amazon.aws.plugins.module_utils.ec2 import (camel_dict_to_snake_dict,
259                                                                     ansible_dict_to_boto3_tag_list,
260                                                                     boto3_tag_list_to_ansible_dict,
261                                                                     )
262
263
264def get_kms_key_aliases(module, client, keyId):
265    """
266    get list of key aliases
267
268    module : AnsibleAWSModule object
269    client : boto3 client connection object for kms
270    keyId : keyId to get aliases for
271    """
272    try:
273        key_resp = client.list_aliases(KeyId=keyId)
274    except (BotoCoreError, ClientError) as err:
275        # Don't fail here, just return [] to maintain backwards compat
276        # in case user doesn't have kms:ListAliases permissions
277        return []
278
279    return key_resp['Aliases']
280
281
282def create_trail(module, client, ct_params):
283    """
284    Creates a CloudTrail
285
286    module : AnsibleAWSModule object
287    client : boto3 client connection object
288    ct_params : The parameters for the Trail to create
289    """
290    resp = {}
291    try:
292        resp = client.create_trail(**ct_params)
293    except (BotoCoreError, ClientError) as err:
294        module.fail_json_aws(err, msg="Failed to create Trail")
295
296    return resp
297
298
299def tag_trail(module, client, tags, trail_arn, curr_tags=None, dry_run=False):
300    """
301    Creates, updates, removes tags on a CloudTrail resource
302
303    module : AnsibleAWSModule object
304    client : boto3 client connection object
305    tags : Dict of tags converted from ansible_dict to boto3 list of dicts
306    trail_arn : The ARN of the CloudTrail to operate on
307    curr_tags : Dict of the current tags on resource, if any
308    dry_run : true/false to determine if changes will be made if needed
309    """
310    adds = []
311    removes = []
312    updates = []
313    changed = False
314
315    if curr_tags is None:
316        # No current tags so just convert all to a tag list
317        adds = ansible_dict_to_boto3_tag_list(tags)
318    else:
319        curr_keys = set(curr_tags.keys())
320        new_keys = set(tags.keys())
321        add_keys = new_keys - curr_keys
322        remove_keys = curr_keys - new_keys
323        update_keys = dict()
324        for k in curr_keys.intersection(new_keys):
325            if curr_tags[k] != tags[k]:
326                update_keys.update({k: tags[k]})
327
328        adds = get_tag_list(add_keys, tags)
329        removes = get_tag_list(remove_keys, curr_tags)
330        updates = get_tag_list(update_keys, tags)
331
332    if removes or updates:
333        changed = True
334        if not dry_run:
335            try:
336                client.remove_tags(ResourceId=trail_arn, TagsList=removes + updates)
337            except (BotoCoreError, ClientError) as err:
338                module.fail_json_aws(err, msg="Failed to remove tags from Trail")
339
340    if updates or adds:
341        changed = True
342        if not dry_run:
343            try:
344                client.add_tags(ResourceId=trail_arn, TagsList=updates + adds)
345            except (BotoCoreError, ClientError) as err:
346                module.fail_json_aws(err, msg="Failed to add tags to Trail")
347
348    return changed
349
350
351def get_tag_list(keys, tags):
352    """
353    Returns a list of dicts with tags to act on
354    keys : set of keys to get the values for
355    tags : the dict of tags to turn into a list
356    """
357    tag_list = []
358    for k in keys:
359        tag_list.append({'Key': k, 'Value': tags[k]})
360
361    return tag_list
362
363
364def set_logging(module, client, name, action):
365    """
366    Starts or stops logging based on given state
367
368    module : AnsibleAWSModule object
369    client : boto3 client connection object
370    name : The name or ARN of the CloudTrail to operate on
371    action : start or stop
372    """
373    if action == 'start':
374        try:
375            client.start_logging(Name=name)
376            return client.get_trail_status(Name=name)
377        except (BotoCoreError, ClientError) as err:
378            module.fail_json_aws(err, msg="Failed to start logging")
379    elif action == 'stop':
380        try:
381            client.stop_logging(Name=name)
382            return client.get_trail_status(Name=name)
383        except (BotoCoreError, ClientError) as err:
384            module.fail_json_aws(err, msg="Failed to stop logging")
385    else:
386        module.fail_json(msg="Unsupported logging action")
387
388
389def get_trail_facts(module, client, name):
390    """
391    Describes existing trail in an account
392
393    module : AnsibleAWSModule object
394    client : boto3 client connection object
395    name : Name of the trail
396    """
397    # get Trail info
398    try:
399        trail_resp = client.describe_trails(trailNameList=[name])
400    except (BotoCoreError, ClientError) as err:
401        module.fail_json_aws(err, msg="Failed to describe Trail")
402
403    # Now check to see if our trail exists and get status and tags
404    if len(trail_resp['trailList']):
405        trail = trail_resp['trailList'][0]
406        try:
407            status_resp = client.get_trail_status(Name=trail['Name'])
408            tags_list = client.list_tags(ResourceIdList=[trail['TrailARN']])
409        except (BotoCoreError, ClientError) as err:
410            module.fail_json_aws(err, msg="Failed to describe Trail")
411
412        trail['IsLogging'] = status_resp['IsLogging']
413        trail['tags'] = boto3_tag_list_to_ansible_dict(tags_list['ResourceTagList'][0]['TagsList'])
414        # Check for non-existent values and populate with None
415        optional_vals = set(['S3KeyPrefix', 'SnsTopicName', 'SnsTopicARN', 'CloudWatchLogsLogGroupArn', 'CloudWatchLogsRoleArn', 'KmsKeyId'])
416        for v in optional_vals - set(trail.keys()):
417            trail[v] = None
418        return trail
419
420    else:
421        # trail doesn't exist return None
422        return None
423
424
425def delete_trail(module, client, trail_arn):
426    """
427    Delete a CloudTrail
428
429    module : AnsibleAWSModule object
430    client : boto3 client connection object
431    trail_arn : Full CloudTrail ARN
432    """
433    try:
434        client.delete_trail(Name=trail_arn)
435    except (BotoCoreError, ClientError) as err:
436        module.fail_json_aws(err, msg="Failed to delete Trail")
437
438
439def update_trail(module, client, ct_params):
440    """
441    Delete a CloudTrail
442
443    module : AnsibleAWSModule object
444    client : boto3 client connection object
445    ct_params : The parameters for the Trail to update
446    """
447    try:
448        client.update_trail(**ct_params)
449    except (BotoCoreError, ClientError) as err:
450        module.fail_json_aws(err, msg="Failed to update Trail")
451
452
453def main():
454    argument_spec = dict(
455        state=dict(default='present', choices=['present', 'absent', 'enabled', 'disabled']),
456        name=dict(default='default'),
457        enable_logging=dict(default=True, type='bool'),
458        s3_bucket_name=dict(),
459        s3_key_prefix=dict(no_log=False),
460        sns_topic_name=dict(),
461        is_multi_region_trail=dict(default=False, type='bool'),
462        enable_log_file_validation=dict(type='bool', aliases=['log_file_validation_enabled']),
463        include_global_events=dict(default=True, type='bool', aliases=['include_global_service_events']),
464        cloudwatch_logs_role_arn=dict(),
465        cloudwatch_logs_log_group_arn=dict(),
466        kms_key_id=dict(),
467        tags=dict(default={}, type='dict'),
468    )
469
470    required_if = [('state', 'present', ['s3_bucket_name']), ('state', 'enabled', ['s3_bucket_name'])]
471    required_together = [('cloudwatch_logs_role_arn', 'cloudwatch_logs_log_group_arn')]
472
473    module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True, required_together=required_together, required_if=required_if)
474
475    # collect parameters
476    if module.params['state'] in ('present', 'enabled'):
477        state = 'present'
478    elif module.params['state'] in ('absent', 'disabled'):
479        state = 'absent'
480    tags = module.params['tags']
481    enable_logging = module.params['enable_logging']
482    ct_params = dict(
483        Name=module.params['name'],
484        S3BucketName=module.params['s3_bucket_name'],
485        IncludeGlobalServiceEvents=module.params['include_global_events'],
486        IsMultiRegionTrail=module.params['is_multi_region_trail'],
487    )
488
489    if module.params['s3_key_prefix']:
490        ct_params['S3KeyPrefix'] = module.params['s3_key_prefix'].rstrip('/')
491
492    if module.params['sns_topic_name']:
493        ct_params['SnsTopicName'] = module.params['sns_topic_name']
494
495    if module.params['cloudwatch_logs_role_arn']:
496        ct_params['CloudWatchLogsRoleArn'] = module.params['cloudwatch_logs_role_arn']
497
498    if module.params['cloudwatch_logs_log_group_arn']:
499        ct_params['CloudWatchLogsLogGroupArn'] = module.params['cloudwatch_logs_log_group_arn']
500
501    if module.params['enable_log_file_validation'] is not None:
502        ct_params['EnableLogFileValidation'] = module.params['enable_log_file_validation']
503
504    if module.params['kms_key_id']:
505        ct_params['KmsKeyId'] = module.params['kms_key_id']
506
507    client = module.client('cloudtrail')
508    region = module.region
509
510    results = dict(
511        changed=False,
512        exists=False
513    )
514
515    # Get existing trail facts
516    trail = get_trail_facts(module, client, ct_params['Name'])
517
518    # If the trail exists set the result exists variable
519    if trail is not None:
520        results['exists'] = True
521        initial_kms_key_id = trail.get('KmsKeyId')
522
523    if state == 'absent' and results['exists']:
524        # If Trail exists go ahead and delete
525        results['changed'] = True
526        results['exists'] = False
527        results['trail'] = dict()
528        if not module.check_mode:
529            delete_trail(module, client, trail['TrailARN'])
530
531    elif state == 'present' and results['exists']:
532        # If Trail exists see if we need to update it
533        do_update = False
534        for key in ct_params:
535            tkey = str(key)
536            # boto3 has inconsistent parameter naming so we handle it here
537            if key == 'EnableLogFileValidation':
538                tkey = 'LogFileValidationEnabled'
539            # We need to make an empty string equal None
540            if ct_params.get(key) == '':
541                val = None
542            else:
543                val = ct_params.get(key)
544            if val != trail.get(tkey):
545                do_update = True
546                if tkey != 'KmsKeyId':
547                    # We'll check if the KmsKeyId casues changes later since
548                    # user could've provided a key alias, alias arn, or key id
549                    # and trail['KmsKeyId'] is always a key arn
550                    results['changed'] = True
551                # If we are in check mode copy the changed values to the trail facts in result output to show what would change.
552                if module.check_mode:
553                    trail.update({tkey: ct_params.get(key)})
554
555        if not module.check_mode and do_update:
556            update_trail(module, client, ct_params)
557            trail = get_trail_facts(module, client, ct_params['Name'])
558
559        # Determine if KmsKeyId changed
560        if not module.check_mode:
561            if initial_kms_key_id != trail.get('KmsKeyId'):
562                results['changed'] = True
563        else:
564            new_key = ct_params.get('KmsKeyId')
565            if initial_kms_key_id != new_key:
566                # Assume changed for a moment
567                results['changed'] = True
568
569                # However, new_key could be a key id, alias arn, or alias name
570                # that maps back to the key arn in initial_kms_key_id. So check
571                # all aliases for a match.
572                initial_aliases = get_kms_key_aliases(module, module.client('kms'), initial_kms_key_id)
573                for a in initial_aliases:
574                    if(a['AliasName'] == new_key or
575                       a['AliasArn'] == new_key or
576                       a['TargetKeyId'] == new_key):
577                        results['changed'] = False
578
579        # Check if we need to start/stop logging
580        if enable_logging and not trail['IsLogging']:
581            results['changed'] = True
582            trail['IsLogging'] = True
583            if not module.check_mode:
584                set_logging(module, client, name=ct_params['Name'], action='start')
585        if not enable_logging and trail['IsLogging']:
586            results['changed'] = True
587            trail['IsLogging'] = False
588            if not module.check_mode:
589                set_logging(module, client, name=ct_params['Name'], action='stop')
590
591        # Check if we need to update tags on resource
592        tag_dry_run = False
593        if module.check_mode:
594            tag_dry_run = True
595        tags_changed = tag_trail(module, client, tags=tags, trail_arn=trail['TrailARN'], curr_tags=trail['tags'], dry_run=tag_dry_run)
596        if tags_changed:
597            results['changed'] = True
598            trail['tags'] = tags
599        # Populate trail facts in output
600        results['trail'] = camel_dict_to_snake_dict(trail, ignore_list=['tags'])
601
602    elif state == 'present' and not results['exists']:
603        # Trail doesn't exist just go create it
604        results['changed'] = True
605        results['exists'] = True
606        if not module.check_mode:
607            # If we aren't in check_mode then actually create it
608            created_trail = create_trail(module, client, ct_params)
609            # Apply tags
610            tag_trail(module, client, tags=tags, trail_arn=created_trail['TrailARN'])
611            # Get the trail status
612            try:
613                status_resp = client.get_trail_status(Name=created_trail['Name'])
614            except (BotoCoreError, ClientError) as err:
615                module.fail_json_aws(err, msg="Failed to fetch Trail statuc")
616            # Set the logging state for the trail to desired value
617            if enable_logging and not status_resp['IsLogging']:
618                set_logging(module, client, name=ct_params['Name'], action='start')
619            if not enable_logging and status_resp['IsLogging']:
620                set_logging(module, client, name=ct_params['Name'], action='stop')
621            # Get facts for newly created Trail
622            trail = get_trail_facts(module, client, ct_params['Name'])
623
624        # If we are in check mode create a fake return structure for the newly minted trail
625        if module.check_mode:
626            acct_id = '123456789012'
627            try:
628                sts_client = module.client('sts')
629                acct_id = sts_client.get_caller_identity()['Account']
630            except (BotoCoreError, ClientError):
631                pass
632            trail = dict()
633            trail.update(ct_params)
634            if 'EnableLogFileValidation' not in ct_params:
635                ct_params['EnableLogFileValidation'] = False
636            trail['EnableLogFileValidation'] = ct_params['EnableLogFileValidation']
637            trail.pop('EnableLogFileValidation')
638            fake_arn = 'arn:aws:cloudtrail:' + region + ':' + acct_id + ':trail/' + ct_params['Name']
639            trail['HasCustomEventSelectors'] = False
640            trail['HomeRegion'] = region
641            trail['TrailARN'] = fake_arn
642            trail['IsLogging'] = enable_logging
643            trail['tags'] = tags
644        # Populate trail facts in output
645        results['trail'] = camel_dict_to_snake_dict(trail, ignore_list=['tags'])
646
647    module.exit_json(**results)
648
649
650if __name__ == '__main__':
651    main()
652