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