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