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