├── ct_configrecorder_override_consumer.zip ├── ct_configrecorder_override_producer.zip ├── CODE_OF_CONDUCT.md ├── LICENSE ├── cfnresponse.py ├── CONTRIBUTING.md ├── CHANGELOG.md ├── README.md ├── ct_configrecorder_override_consumer.py ├── ct_configrecorder_override_producer.py └── template.yaml /ct_configrecorder_override_consumer.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-control-tower-config-customization/HEAD/ct_configrecorder_override_consumer.zip -------------------------------------------------------------------------------- /ct_configrecorder_override_producer.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-control-tower-config-customization/HEAD/ct_configrecorder_override_producer.zip -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /cfnresponse.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from __future__ import print_function 5 | import urllib3 6 | import json 7 | 8 | SUCCESS = "SUCCESS" 9 | FAILED = "FAILED" 10 | 11 | http = urllib3.PoolManager() 12 | 13 | 14 | def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): 15 | responseUrl = event['ResponseURL'] 16 | 17 | print(responseUrl) 18 | 19 | responseBody = { 20 | 'Status' : responseStatus, 21 | 'Reason' : reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), 22 | 'PhysicalResourceId' : physicalResourceId or context.log_stream_name, 23 | 'StackId' : event['StackId'], 24 | 'RequestId' : event['RequestId'], 25 | 'LogicalResourceId' : event['LogicalResourceId'], 26 | 'NoEcho' : noEcho, 27 | 'Data' : responseData 28 | } 29 | 30 | json_responseBody = json.dumps(responseBody) 31 | 32 | print("Response body:") 33 | print(json_responseBody) 34 | 35 | headers = { 36 | 'content-type' : '', 37 | 'content-length' : str(len(json_responseBody)) 38 | } 39 | 40 | try: 41 | response = http.request('PUT', responseUrl, headers=headers, body=json_responseBody) 42 | print("Status code:", response.status) 43 | 44 | 45 | except Exception as e: 46 | 47 | print("send(..) failed executing http.request(..):", e) -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. Please test for Create, Update and Delete stack with various of parameters. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.0] - 2025-11-16 9 | 10 | ### Added 11 | - Account inclusion mode feature allowing administrators to choose between EXCLUSION and INCLUSION modes for account targeting 12 | - `AccountSelectionMode` parameter with allowed values EXCLUSION (default) and INCLUSION 13 | - `IncludedAccounts` parameter for specifying accounts to include when using INCLUSION mode 14 | - "Account Selection Settings" parameter group in CloudFormation template for better organization 15 | - `should_process_account` function in Producer Lambda for centralized account filtering logic 16 | - Comprehensive logging of account filtering decisions with reasons for inclusion/exclusion 17 | 18 | ### Changed 19 | - Producer Lambda now supports two account selection modes: EXCLUSION (existing behavior) and INCLUSION (new behavior) 20 | - Updated `send_message_to_sqs` function to use new `should_process_account` filtering logic 21 | - Updated `override_config_recorder` function to accept and pass through account selection parameters 22 | - Updated `update_excluded_accounts` function to handle both EXCLUSION and INCLUSION modes appropriately 23 | - Producer Lambda environment variables now include `ACCOUNT_SELECTION_MODE` and `INCLUDED_ACCOUNTS` 24 | 25 | ### Documentation 26 | - Added comprehensive explanation of EXCLUSION vs INCLUSION modes in README 27 | - Added usage examples for INCLUSION mode configuration 28 | - Documented parameter usage clarifying when IncludedAccounts and ExcludedAccounts are used 29 | - Added warning about Management, Log Archive, and Audit accounts in inclusion mode 30 | - Documented backward compatibility guarantees for existing deployments 31 | 32 | ### Backward Compatibility 33 | - Existing deployments continue to work without modification - `AccountSelectionMode` defaults to EXCLUSION 34 | - No changes required to Consumer Lambda - maintains single responsibility of applying Config Recorder changes 35 | - Existing `ExcludedAccounts` parameter remains unchanged and continues to work in EXCLUSION mode 36 | - All existing CloudFormation parameters, IAM permissions, and SQS message formats remain compatible 37 | - Zero-risk upgrade path: updating stack without changing parameters results in identical behavior 38 | 39 | ## [1.1.0] - 2025-11-15 40 | 41 | ### Added 42 | - Added `ResetLandingZone` event trigger to ProducerEventTrigger to handle landing zone reset operations 43 | - Documented resource retention policy in README Known Limitations section 44 | - Added explanation of race condition prevention through resource retention 45 | 46 | ### Changed 47 | - Updated ProducerEventTrigger EventPattern to include `ResetLandingZone` event alongside existing triggers (`UpdateLandingZone`, `CreateManagedAccount`, `UpdateManagedAccount`) 48 | 49 | ### Fixed 50 | - Corrected incorrect log lines in consumer and producer Lambda functions ([#33](https://github.com/aws-samples/aws-control-tower-config-customization/pull/33)) 51 | - Fixed STS client to use regional endpoints for opt-in regions support - prevents `UnrecognizedClientException` in opt-in regions (me-south-1, ap-east-1, etc.) 52 | 53 | ### Documentation 54 | - Added comprehensive "Resource Retention After Stack Deletion" section to README 55 | - Documented all 6 resources with DeletionPolicy: Retain (Lambda Functions, IAM Roles, SQS Queue, Lambda Permissions, Event Source Mapping) 56 | - Added manual cleanup instructions for retained resources 57 | - Clarified that retention prevents race conditions during config rollback operations 58 | 59 | ## [1.0.0] - 2024 60 | 61 | ### Added 62 | - Flexibility for AWS Config recording strategies supporting both INCLUSION and EXCLUSION lists for resource types 63 | - `ConfigRecorderIncludedResourceTypes` parameter for inclusion-based recording 64 | - `ConfigRecorderExcludedResourceTypes` parameter for exclusion-based recording 65 | - Support for daily recording frequency for specific resource types ([#13](https://github.com/aws-samples/aws-control-tower-config-customization/pull/13)) 66 | - `ConfigRecorderDailyResourceTypes` parameter for resources recorded on daily cadence 67 | - `ConfigRecorderDailyGlobalResourceTypes` parameter for global resources in Control Tower home region 68 | - `ConfigRecorderDefaultRecordingFrequency` parameter (CONTINUOUS or DAILY) 69 | 70 | ### Fixed 71 | - Maximum number of configuration recorders exceeded exception by using existing recorder name ([#24](https://github.com/aws-samples/aws-control-tower-config-customization/pull/24)) 72 | - Global resource recording now correctly limited to Control Tower home region ([#19](https://github.com/aws-samples/aws-control-tower-config-customization/pull/19)) 73 | - `includeGlobalResourceTypes` value properly set to true only in home region 74 | 75 | ### Changed 76 | - Updated IAM role handling for configuration recorder 77 | 78 | ## [0.1.0] - Initial Release 79 | 80 | ### Added 81 | - Initial CloudFormation template for AWS Config resource tracking customization in Control Tower environments 82 | - Producer Lambda function for detecting Control Tower events 83 | - Consumer Lambda function for updating Config Recorder settings 84 | - SQS queue for event processing 85 | - IAM roles and permissions for Lambda execution 86 | - EventBridge rule for triggering on Control Tower lifecycle events 87 | - Support for resource exclusion in Config Recorder 88 | - Configurable excluded accounts list 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Customize AWS Config resource tracking in AWS Control Tower environment 2 | This github repository is part of AWS blog post https://aws.amazon.com/blogs/mt/customize-aws-config-resource-tracking-in-aws-control-tower-environment/ 3 | 4 | Please refer to the blog for what this sample code does and how to use it. 5 | 6 | ## CloudFormation Parameters 7 | 8 | This solution uses CloudFormation parameters to customize the AWS Config Recorder behavior across your Control Tower environment. Parameters are organized into four categories: 9 | 10 | ## Account Selection Modes 11 | 12 | This solution supports two modes for selecting which AWS accounts receive Config Recorder customizations: 13 | 14 | ### EXCLUSION Mode (Default) 15 | In **EXCLUSION mode**, the solution applies Config Recorder changes to **all Control Tower managed accounts except** those explicitly listed in the `ExcludedAccounts` parameter. This is the default behavior and is ideal when you want to customize most accounts while protecting a few critical accounts. 16 | 17 | **When to use EXCLUSION mode:** 18 | - You want to apply Config Recorder customizations to the majority of your accounts 19 | - You have a small number of accounts that should maintain default Control Tower Config settings 20 | - You want to protect specific accounts (Management, Log Archive, Audit) from modifications 21 | 22 | ### INCLUSION Mode 23 | In **INCLUSION mode**, the solution applies Config Recorder changes **only to accounts** explicitly listed in the `IncludedAccounts` parameter. All other accounts are automatically excluded. This mode is ideal when you want precise control over a specific subset of accounts. 24 | 25 | **When to use INCLUSION mode:** 26 | - You want to apply Config Recorder customizations to only a few specific accounts 27 | - You're testing the solution in a limited scope before broader rollout 28 | - You have a small number of workload accounts that need customization 29 | - You want explicit control over which accounts are affected 30 | 31 | ### Important Warning 32 | 33 | ⚠️ **Critical Accounts**: Regardless of which mode you use, you should typically **NOT** include the following accounts in your customizations: 34 | - **Management Account**: The Control Tower management account 35 | - **Log Archive Account**: The centralized logging account 36 | - **Audit Account**: The security audit account 37 | 38 | These accounts have special roles in Control Tower governance and should generally maintain their default AWS Config Recorder settings to ensure proper Control Tower functionality. 39 | 40 | ### Mandatory Settings 41 | 42 | #### CloudFormationVersion 43 | - **Description**: Version number to force stack updates and rerun the solution 44 | - **Type**: String 45 | - **Default**: `1` 46 | - **Usage**: Increment this value whenever you need to force the solution to re-execute across all accounts 47 | 48 | #### SourceS3Bucket 49 | - **Description**: S3 bucket containing Lambda deployment packages 50 | - **Type**: String 51 | - **Default**: `marketplace-sa-resources` 52 | - **Usage**: Leave as default unless you've customized the Lambda function code and stored it in your own S3 bucket 53 | 54 | ### Account Selection Settings 55 | 56 | #### AccountSelectionMode 57 | - **Description**: Determines whether to use exclusion or inclusion mode for account targeting 58 | - **Type**: String 59 | - **Default**: `EXCLUSION` 60 | - **Allowed Values**: `EXCLUSION`, `INCLUSION` 61 | - **Usage**: 62 | - `EXCLUSION`: Apply Config Recorder changes to all accounts except those in `ExcludedAccounts` 63 | - `INCLUSION`: Apply Config Recorder changes only to accounts in `IncludedAccounts` 64 | - **Backward Compatibility**: Defaults to `EXCLUSION` to maintain existing behavior for upgraded deployments 65 | 66 | #### ExcludedAccounts 67 | - **Description**: List of AWS account IDs to exclude from Config Recorder customization 68 | - **Type**: String (Python list format) 69 | - **Default**: `['111111111111', '222222222222', '333333333333']` 70 | - **Constraints**: 36-4096 characters 71 | - **When Used**: Only applies when `AccountSelectionMode` is set to `EXCLUSION` 72 | - **Required Accounts**: Should include Management account, Log Archive account, and Audit account at minimum 73 | - **Usage**: Replace default values with your actual account IDs that should not have Config Recorder modifications 74 | - **Example**: `['123456789012', '234567890123', '345678901234']` 75 | 76 | #### IncludedAccounts 77 | - **Description**: List of AWS account IDs to include for Config Recorder customization 78 | - **Type**: String (Python list format) 79 | - **Default**: `[]` (empty list) 80 | - **Constraints**: 2-4096 characters 81 | - **When Used**: Only applies when `AccountSelectionMode` is set to `INCLUSION` 82 | - **Usage**: Specify the exact accounts that should receive Config Recorder customizations 83 | - **Example**: `['123456789012', '234567890123']` 84 | - **Note**: If empty while in INCLUSION mode, no accounts will be processed 85 | 86 | ### Recording Strategy Settings 87 | 88 | #### ConfigRecorderStrategy 89 | - **Description**: Strategy for resource recording in AWS Config 90 | - **Type**: String 91 | - **Default**: `EXCLUSION` 92 | - **Allowed Values**: `EXCLUSION`, `INCLUSION` 93 | - **Usage**: 94 | - `EXCLUSION`: Record all resources except those specified in `ConfigRecorderExcludedResourceTypes` 95 | - `INCLUSION`: Only record resources specified in `ConfigRecorderIncludedResourceTypes` 96 | 97 | #### ConfigRecorderExcludedResourceTypes 98 | - **Description**: Comma-separated list of AWS resource types to exclude from recording 99 | - **Type**: String 100 | - **Default**: `AWS::HealthLake::FHIRDatastore,AWS::Pinpoint::Segment,AWS::Pinpoint::ApplicationSettings` 101 | - **Usage**: Only applies when `ConfigRecorderStrategy` is set to `EXCLUSION` 102 | - **Example**: `AWS::EC2::Volume,AWS::S3::Bucket,AWS::RDS::DBInstance` 103 | 104 | #### ConfigRecorderIncludedResourceTypes 105 | - **Description**: Comma-separated list of AWS resource types to include in recording 106 | - **Type**: String 107 | - **Default**: `AWS::S3::Bucket,AWS::CloudTrail::Trail` 108 | - **Usage**: Only applies when `ConfigRecorderStrategy` is set to `INCLUSION` 109 | - **Example**: `AWS::IAM::Role,AWS::IAM::Policy,AWS::EC2::Instance` 110 | 111 | ### Recording Frequency Settings 112 | 113 | #### ConfigRecorderDefaultRecordingFrequency 114 | - **Description**: Default frequency for recording configuration changes 115 | - **Type**: String 116 | - **Default**: `CONTINUOUS` 117 | - **Allowed Values**: `CONTINUOUS`, `DAILY` 118 | - **Usage**: 119 | - `CONTINUOUS`: Records configuration changes as they occur (higher AWS Config costs) 120 | - `DAILY`: Records configuration once per day (lower costs, 24-hour detection delay) 121 | 122 | #### ConfigRecorderDailyResourceTypes 123 | - **Description**: Comma-separated list of resource types to record on a daily cadence 124 | - **Type**: String 125 | - **Default**: `AWS::AutoScaling::AutoScalingGroup,AWS::AutoScaling::LaunchConfiguration` 126 | - **Usage**: Resources listed here will be recorded daily regardless of `ConfigRecorderDefaultRecordingFrequency` setting 127 | - **Example**: `AWS::EC2::Volume,AWS::Lambda::Function` 128 | 129 | #### ConfigRecorderDailyGlobalResourceTypes 130 | - **Description**: Comma-separated list of global resource types to record daily in the Control Tower home region 131 | - **Type**: String 132 | - **Default**: `AWS::IAM::Policy,AWS::IAM::User,AWS::IAM::Role,AWS::IAM::Group` 133 | - **Usage**: Global resources (IAM, CloudFront, etc.) are only recorded in the home region to avoid duplication 134 | - **Note**: These resources are automatically added to daily recording in the Control Tower home region only 135 | 136 | ## Usage Examples 137 | 138 | ### Example 1: Exclude specific high-volume resource types 139 | ```yaml 140 | ConfigRecorderStrategy: EXCLUSION 141 | ConfigRecorderExcludedResourceTypes: "AWS::EC2::NetworkInterface,AWS::EC2::Volume,AWS::Lambda::Function" 142 | ConfigRecorderDefaultRecordingFrequency: CONTINUOUS 143 | ``` 144 | 145 | ### Example 2: Only track security-critical resources with daily recording 146 | ```yaml 147 | ConfigRecorderStrategy: INCLUSION 148 | ConfigRecorderIncludedResourceTypes: "AWS::IAM::Role,AWS::IAM::Policy,AWS::S3::Bucket,AWS::KMS::Key" 149 | ConfigRecorderDefaultRecordingFrequency: DAILY 150 | ``` 151 | 152 | ### Example 3: Mixed recording frequencies for cost optimization 153 | ```yaml 154 | ConfigRecorderStrategy: EXCLUSION 155 | ConfigRecorderExcludedResourceTypes: "AWS::EC2::NetworkInterface" 156 | ConfigRecorderDefaultRecordingFrequency: DAILY 157 | ConfigRecorderDailyResourceTypes: "AWS::EC2::Instance,AWS::RDS::DBInstance" 158 | ``` 159 | 160 | ### Example 4: Using INCLUSION mode to target specific workload accounts 161 | ```yaml 162 | AccountSelectionMode: INCLUSION 163 | IncludedAccounts: "['123456789012', '234567890123', '345678901234']" 164 | ConfigRecorderStrategy: EXCLUSION 165 | ConfigRecorderExcludedResourceTypes: "AWS::EC2::NetworkInterface,AWS::EC2::Volume" 166 | ConfigRecorderDefaultRecordingFrequency: CONTINUOUS 167 | ``` 168 | **Use case**: Apply Config Recorder customizations only to three specific workload accounts while leaving all other accounts with default Control Tower settings. 169 | 170 | ### Example 5: Using EXCLUSION mode (default behavior) 171 | ```yaml 172 | AccountSelectionMode: EXCLUSION 173 | ExcludedAccounts: "['111111111111', '222222222222', '333333333333']" 174 | ConfigRecorderStrategy: INCLUSION 175 | ConfigRecorderIncludedResourceTypes: "AWS::IAM::Role,AWS::IAM::Policy,AWS::S3::Bucket" 176 | ConfigRecorderDefaultRecordingFrequency: DAILY 177 | ``` 178 | **Use case**: Apply Config Recorder customizations to all Control Tower accounts except the Management, Log Archive, and Audit accounts (replace with your actual account IDs). 179 | 180 | ### Example 6: Switching from EXCLUSION to INCLUSION mode 181 | If you have an existing deployment using EXCLUSION mode and want to switch to INCLUSION mode: 182 | 183 | **Current configuration (EXCLUSION mode):** 184 | ```yaml 185 | AccountSelectionMode: EXCLUSION 186 | ExcludedAccounts: "['111111111111', '222222222222', '333333333333']" 187 | ``` 188 | 189 | **New configuration (INCLUSION mode):** 190 | ```yaml 191 | AccountSelectionMode: INCLUSION 192 | IncludedAccounts: "['123456789012', '234567890123']" 193 | # ExcludedAccounts parameter is ignored when AccountSelectionMode is INCLUSION 194 | ``` 195 | 196 | **Steps to switch:** 197 | 1. Identify all Control Tower managed accounts in your organization 198 | 2. Determine which specific accounts need Config Recorder customizations 199 | 3. Update the CloudFormation stack with: 200 | - `AccountSelectionMode` set to `INCLUSION` 201 | - `IncludedAccounts` set to your target account list 202 | 4. The `ExcludedAccounts` parameter will be ignored in INCLUSION mode 203 | 204 | ## Backward Compatibility 205 | 206 | This solution maintains full backward compatibility with existing deployments: 207 | 208 | ### Upgrading Existing Deployments 209 | 210 | When you update an existing CloudFormation stack to a newer version of this solution: 211 | 212 | - **No parameter changes required**: The stack will continue to operate in EXCLUSION mode with your existing `ExcludedAccounts` configuration 213 | - **Default behavior preserved**: `AccountSelectionMode` defaults to `EXCLUSION`, maintaining identical behavior to previous versions 214 | - **Zero-risk upgrade**: Simply updating the stack without changing parameters will not alter which accounts receive Config Recorder customizations 215 | - **Gradual adoption**: You can adopt INCLUSION mode at your own pace by explicitly changing the `AccountSelectionMode` parameter 216 | 217 | ### Parameter Behavior by Mode 218 | 219 | | Parameter | Used in EXCLUSION Mode | Used in INCLUSION Mode | 220 | |-----------|------------------------|------------------------| 221 | | `AccountSelectionMode` | ✅ Required (set to `EXCLUSION`) | ✅ Required (set to `INCLUSION`) | 222 | | `ExcludedAccounts` | ✅ Used to filter accounts | ❌ Ignored | 223 | | `IncludedAccounts` | ❌ Ignored | ✅ Used to filter accounts | 224 | 225 | ### Migration Safety 226 | 227 | - **EXCLUSION to INCLUSION**: Switching modes requires explicit parameter changes - no accidental mode switches 228 | - **INCLUSION to EXCLUSION**: Can switch back at any time by changing `AccountSelectionMode` to `EXCLUSION` 229 | - **Empty lists**: 230 | - Empty `ExcludedAccounts` in EXCLUSION mode = all accounts processed (safe default) 231 | - Empty `IncludedAccounts` in INCLUSION mode = no accounts processed (safe default) 232 | 233 | ## Security 234 | 235 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 236 | 237 | 238 | ## Known limitations 239 | 240 | ### Resource Retention After Stack Deletion 241 | 242 | When you delete the CloudFormation stack, the following resources are intentionally retained to prevent race conditions and allow for complete rollback of AWS Config settings to their default Control Tower configuration: 243 | 244 | - **Lambda Functions**: `ProducerLambda` and `ConsumerLambda` 245 | - **Lambda Permissions**: `ProducerLambdaPermissions` 246 | - **Lambda Event Source Mapping**: `ConsumerLambdaEventSourceMapping` 247 | - **IAM Roles**: `ProducerLambdaExecutionRole` and `ConsumerLambdaExecutionRole` 248 | - **SQS Queue**: `SQSConfigRecorder` 249 | 250 | **Important**: These retained resources will continue to incur minimal costs. If you want to completely remove all resources after stack deletion, you must manually delete these retained resources from the AWS Console or using the AWS CLI. 251 | 252 | To manually clean up retained resources after stack deletion: 253 | 1. Delete the Lambda functions via the Lambda console 254 | 2. Delete the IAM roles via the IAM console 255 | 3. Delete the SQS queue via the SQS console 256 | 4. Lambda permissions and event source mappings will be automatically removed when their associated functions are deleted 257 | 258 | 259 | ## License 260 | 261 | This library is licensed under the MIT-0 License. See the LICENSE file. 262 | -------------------------------------------------------------------------------- /ct_configrecorder_override_consumer.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the "Software"), 7 | # to deal in the Software without restriction, including without limitation 8 | # the rights to use, copy, modify,merge, publish, distribute, sublicense, 9 | # and/or sell copies of the Software, and to permit persons to whom the 10 | # Software is furnished to do so. 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 18 | # IN THE SOFTWARE. 19 | # 20 | 21 | import boto3 22 | import json 23 | import logging 24 | import botocore.exceptions 25 | import os 26 | 27 | 28 | def lambda_handler(event, context): 29 | LOG_LEVEL = os.getenv('LOG_LEVEL') 30 | logging.getLogger().setLevel(LOG_LEVEL) 31 | 32 | try: 33 | logging.info(f'Event: {str(event).replace(chr(10), "").replace(chr(13), "")}') 34 | 35 | body = json.loads(event['Records'][0]['body']) 36 | account_id = body['Account'] 37 | aws_region = body['Region'] 38 | event = body['Event'] 39 | 40 | logging.info(f'Extracted Account: {str(account_id).replace(chr(10), "").replace(chr(13), "")}') 41 | logging.info(f'Extracted Region: {str(aws_region).replace(chr(10), "").replace(chr(13), "")}') 42 | logging.info(f'Extracted Event: {str(event).replace(chr(10), "").replace(chr(13), "")}') 43 | 44 | bc = botocore.__version__ 45 | b3 = boto3.__version__ 46 | 47 | logging.info(f'Botocore : {bc}') 48 | logging.info(f'Boto3 : {b3}') 49 | 50 | STS = boto3.client("sts", region_name=aws_region) 51 | 52 | def assume_role(account_id, role='AWSControlTowerExecution'): 53 | ''' 54 | Return a session in the target account using Control Tower Role 55 | ''' 56 | try: 57 | curr_account = STS.get_caller_identity()['Account'] 58 | if curr_account != account_id: 59 | part = STS.get_caller_identity()['Arn'].split(":")[1] 60 | 61 | role_arn = 'arn:' + part + ':iam::' + account_id + ':role/' + role 62 | ses_name = str(account_id + '-' + role) 63 | response = STS.assume_role(RoleArn=role_arn, RoleSessionName=ses_name) 64 | sts_session = boto3.Session( 65 | aws_access_key_id=response['Credentials']['AccessKeyId'], 66 | aws_secret_access_key=response['Credentials']['SecretAccessKey'], 67 | aws_session_token=response['Credentials']['SessionToken']) 68 | 69 | return sts_session 70 | except botocore.exceptions.ClientError as exe: 71 | logging.error('Unable to assume role') 72 | raise exe 73 | 74 | sts_session = assume_role(account_id) 75 | logging.info(f'Printing STS session: {sts_session}') 76 | 77 | # Use the session and create a client for configservice 78 | configservice = sts_session.client('config', region_name=aws_region) 79 | 80 | # Describe configuration recorder 81 | configrecorder = configservice.describe_configuration_recorders() 82 | logging.info(f'Existing Configuration Recorder: {configrecorder}') 83 | 84 | # Get the name of the existing recorder if it exists, otherwise use the default name 85 | recorder_name = 'aws-controltower-BaselineConfigRecorder' 86 | if configrecorder and 'ConfigurationRecorders' in configrecorder and len(configrecorder['ConfigurationRecorders']) > 0: 87 | recorder_name = configrecorder['ConfigurationRecorders'][0]['name'] 88 | logging.info(f'Using existing recorder name: {recorder_name}') 89 | 90 | # ControlTower created configuration recorder with name "aws-controltower-BaselineConfigRecorder" and we will update just that 91 | try: 92 | role_arn = 'arn:aws:iam::' + account_id + ':role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig' 93 | 94 | CONFIG_RECORDER_DAILY_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST', '') 95 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST = CONFIG_RECORDER_DAILY_RESOURCE_STRING.split( 96 | ',') if CONFIG_RECORDER_DAILY_RESOURCE_STRING != '' else [] 97 | 98 | CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_DAILY_GLOBAL_RESOURCE_LIST', '') 99 | CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_LIST = CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING.split( 100 | ',') if CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_STRING != '' else [] 101 | 102 | # Get resource lists for both strategies 103 | CONFIG_RECORDER_STRATEGY = os.getenv('CONFIG_RECORDER_STRATEGY', 'EXCLUSION') 104 | 105 | # Get exclusion list (original behavior) 106 | CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_EXCLUDED_RESOURCE_LIST', '') 107 | CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST = CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING.split( 108 | ',') if CONFIG_RECORDER_EXCLUSION_RESOURCE_STRING != '' else [] 109 | 110 | # Get inclusion list (new behavior) 111 | CONFIG_RECORDER_INCLUSION_RESOURCE_STRING = os.getenv('CONFIG_RECORDER_OVERRIDE_INCLUDED_RESOURCE_LIST', '') 112 | CONFIG_RECORDER_INCLUSION_RESOURCE_LIST = CONFIG_RECORDER_INCLUSION_RESOURCE_STRING.split( 113 | ',') if CONFIG_RECORDER_INCLUSION_RESOURCE_STRING != '' else [] 114 | 115 | CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY = os.getenv('CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY') 116 | 117 | # For exclusion strategy, remove any resource type from daily list that are in exclusion list 118 | if CONFIG_RECORDER_STRATEGY == 'EXCLUSION': 119 | res = [x for x in CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST if x not in CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST] 120 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST[:] = res 121 | else: # For inclusion strategy, make sure all daily resources are in the inclusion list 122 | for resource_type in CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST: 123 | if resource_type not in CONFIG_RECORDER_INCLUSION_RESOURCE_LIST: 124 | CONFIG_RECORDER_INCLUSION_RESOURCE_LIST.append(resource_type) 125 | 126 | # Event = Delete is when stack is deleted, we rollback changed made and leave it as ControlTower Intended 127 | home_region = os.getenv('CONTROL_TOWER_HOME_REGION') == aws_region 128 | if home_region: 129 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST += CONFIG_RECORDER_DAILY_GLOBAL_RESOURCE_LIST 130 | 131 | if event == 'Delete': 132 | response = configservice.put_configuration_recorder( 133 | ConfigurationRecorder={ 134 | 'name': recorder_name, 135 | 'roleARN': role_arn, 136 | 'recordingGroup': { 137 | 'allSupported': True, 138 | 'includeGlobalResourceTypes': home_region 139 | } 140 | }) 141 | logging.warning( 142 | f"Configuration Recorder reset to default. Response: {json.dumps(response, default=str)}" 143 | ) 144 | else: 145 | if CONFIG_RECORDER_STRATEGY == 'EXCLUSION': 146 | # Original exclusion-based code - EXACTLY as in the working version 147 | logging.info(f'Using EXCLUSION strategy') 148 | logging.info(f'Exclusion resource list: {CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST}') 149 | logging.info(f'Daily override resource list: {CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST}') 150 | 151 | config_recorder = { 152 | 'name': recorder_name, 153 | 'roleARN': role_arn, 154 | 'recordingGroup': { 155 | 'allSupported': False, 156 | 'includeGlobalResourceTypes': False, 157 | 'exclusionByResourceTypes': { 158 | 'resourceTypes': CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST 159 | }, 160 | 'recordingStrategy': { 161 | 'useOnly': 'EXCLUSION_BY_RESOURCE_TYPES' 162 | } 163 | }, 164 | 'recordingMode': { 165 | 'recordingFrequency': CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY, 166 | 'recordingModeOverrides': [ 167 | { 168 | 'description': 'DAILY_OVERRIDE', 169 | 'resourceTypes': CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST, 170 | 'recordingFrequency': 'DAILY' 171 | } 172 | ] if CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST else [] 173 | } 174 | } 175 | 176 | if not CONFIG_RECORDER_EXCLUSION_RESOURCE_LIST: 177 | config_recorder['recordingGroup'].pop('exclusionByResourceTypes') 178 | config_recorder['recordingGroup'].pop('recordingStrategy') 179 | config_recorder['recordingGroup']['allSupported'] = True 180 | config_recorder['recordingGroup']['includeGlobalResourceTypes'] = True 181 | else: 182 | # New inclusion-based code 183 | logging.info(f'Using INCLUSION strategy') 184 | # Make sure all resources in daily overrides are also in the inclusion list 185 | for resource_type in CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST: 186 | if resource_type not in CONFIG_RECORDER_INCLUSION_RESOURCE_LIST: 187 | CONFIG_RECORDER_INCLUSION_RESOURCE_LIST.append(resource_type) 188 | 189 | logging.info(f'Inclusion resource list: {CONFIG_RECORDER_INCLUSION_RESOURCE_LIST}') 190 | logging.info(f'Daily override resource list: {CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST}') 191 | 192 | config_recorder = { 193 | 'name': recorder_name, 194 | 'roleARN': role_arn 195 | } 196 | 197 | # Set up recording group 198 | if not CONFIG_RECORDER_INCLUSION_RESOURCE_LIST: 199 | config_recorder['recordingGroup'] = { 200 | 'allSupported': False, 201 | 'includeGlobalResourceTypes': False 202 | } 203 | else: 204 | config_recorder['recordingGroup'] = { 205 | 'allSupported': False, 206 | 'includeGlobalResourceTypes': False, 207 | 'resourceTypes': CONFIG_RECORDER_INCLUSION_RESOURCE_LIST, 208 | 'recordingStrategy': { 209 | 'useOnly': 'INCLUSION_BY_RESOURCE_TYPES' 210 | } 211 | } 212 | 213 | # Set up recording mode only if we have daily overrides 214 | if CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST: 215 | config_recorder['recordingMode'] = { 216 | 'recordingFrequency': CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY, 217 | 'recordingModeOverrides': [ 218 | { 219 | 'description': 'DAILY_OVERRIDE', 220 | 'resourceTypes': CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST, 221 | 'recordingFrequency': 'DAILY' 222 | } 223 | ] 224 | } 225 | 226 | response = configservice.put_configuration_recorder( 227 | ConfigurationRecorder=config_recorder) 228 | logging.info(f'Response for put_configuration_recorder :{response} ') 229 | 230 | # lets describe for configuration recorder after the update 231 | configrecorder = configservice.describe_configuration_recorders() 232 | logging.info(f'Post Change Configuration recorder : {configrecorder}') 233 | 234 | except botocore.exceptions.ClientError as exe: 235 | logging.error('Unable to Update Config Recorder for Account and Region : ', account_id, aws_region) 236 | configrecorder = configservice.describe_configuration_recorders() 237 | logging.info(f'Exception : {configrecorder}') 238 | raise exe 239 | 240 | return { 241 | 'statusCode': 200 242 | } 243 | 244 | except Exception as e: 245 | exception_type = e.__class__.__name__ 246 | exception_message = str(e) 247 | logging.exception(f'{exception_type}: {exception_message}') 248 | -------------------------------------------------------------------------------- /ct_configrecorder_override_producer.py: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | # SPDX-License-Identifier: MIT-0 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining 7 | # a copy of this software and associated documentation files (the "Software"), 8 | # to deal in the Software without restriction, including without limitation 9 | # the rights to use, copy, modify,merge, publish, distribute, sublicense, 10 | # and/or sell copies of the Software, and to permit persons to whom the 11 | # Software is furnished to do so. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | # IN THE SOFTWARE. 20 | # 21 | 22 | import boto3 23 | import cfnresponse 24 | import os 25 | import logging 26 | import ast 27 | 28 | def should_process_account(account_id, selection_mode, excluded_accounts, included_accounts): 29 | """ 30 | Determine if an account should be processed based on selection mode. 31 | 32 | Args: 33 | account_id (str): AWS account ID to check (e.g., '123456789012') 34 | selection_mode (str): Either 'EXCLUSION' or 'INCLUSION' 35 | excluded_accounts (list): List of account ID strings to exclude (used in EXCLUSION mode) 36 | included_accounts (list): List of account ID strings to include (used in INCLUSION mode) 37 | 38 | Returns: 39 | bool: True if account should be processed, False otherwise 40 | 41 | Example: 42 | should_process_account('123456789012', 'INCLUSION', [], ['123456789012', '234567890123']) 43 | # Returns True because account is in the inclusion list 44 | """ 45 | if selection_mode == 'INCLUSION': 46 | # In inclusion mode, only process accounts in the included list 47 | should_process = account_id in included_accounts 48 | if should_process: 49 | logging.info(f'Account {account_id} included (in inclusion list)') 50 | else: 51 | logging.info(f'Account {account_id} excluded (not in inclusion list)') 52 | return should_process 53 | else: # EXCLUSION mode (default) 54 | # In exclusion mode, process all accounts except those in excluded list 55 | should_process = account_id not in excluded_accounts 56 | if should_process: 57 | logging.info(f'Account {account_id} included (not in exclusion list)') 58 | else: 59 | logging.info(f'Account {account_id} excluded (in exclusion list)') 60 | return should_process 61 | 62 | def lambda_handler(event, context): 63 | 64 | LOG_LEVEL = os.getenv('LOG_LEVEL') 65 | logging.getLogger().setLevel(LOG_LEVEL) 66 | 67 | try: 68 | logging.info('Event Data: ') 69 | logging.info(event) 70 | sqs_url = os.getenv('SQS_URL') 71 | 72 | # Read environment variables (UPPER_CASE) into local variables (lower_case) 73 | selection_mode = os.getenv('ACCOUNT_SELECTION_MODE', 'EXCLUSION') 74 | excluded_accounts_str = os.getenv('EXCLUDED_ACCOUNTS', '[]') 75 | included_accounts_str = os.getenv('INCLUDED_ACCOUNTS', '[]') 76 | 77 | logging.info(f'Account Selection Mode: {selection_mode}') 78 | logging.info(f'Excluded Accounts: {excluded_accounts_str}') 79 | logging.info(f'Included Accounts: {included_accounts_str}') 80 | 81 | # Parse account lists from string format to Python lists 82 | try: 83 | excluded_accounts = ast.literal_eval(excluded_accounts_str) 84 | except (ValueError, SyntaxError) as e: 85 | logging.error(f'Failed to parse excluded accounts: {e}') 86 | excluded_accounts = [] 87 | 88 | try: 89 | included_accounts = ast.literal_eval(included_accounts_str) 90 | except (ValueError, SyntaxError) as e: 91 | logging.error(f'Failed to parse included accounts: {e}') 92 | included_accounts = [] 93 | 94 | sqs_client = boto3.client('sqs') 95 | 96 | # Check if the lambda was trigerred from EventBridge. 97 | # If so extract Account and Event info from the event data. 98 | 99 | is_eb_trigerred = 'source' in event 100 | 101 | logging.info(f'Is EventBridge Trigerred: {str(is_eb_trigerred)}') 102 | event_source = '' 103 | 104 | if is_eb_trigerred: 105 | event_source = event['source'] 106 | logging.info(f'Control Tower Event Source: {event_source}') 107 | event_name = event['detail']['eventName'] 108 | logging.info(f'Control Tower Event Name: {event_name}') 109 | 110 | if event_source == 'aws.controltower' and event_name == 'UpdateManagedAccount': 111 | account = event['detail']['serviceEventDetails']['updateManagedAccountStatus']['account']['accountId'] 112 | logging.info(f'overriding config recorder for SINGLE account: {account}') 113 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, account, 'controltower') 114 | elif event_source == 'aws.controltower' and event_name == 'CreateManagedAccount': 115 | account = event['detail']['serviceEventDetails']['createManagedAccountStatus']['account']['accountId'] 116 | logging.info(f'overriding config recorder for SINGLE account: {account}') 117 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, account, 'controltower') 118 | elif event_source == 'aws.controltower' and event_name == 'UpdateLandingZone': 119 | logging.info('overriding config recorder for ALL accounts due to UpdateLandingZone event') 120 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, '', 'controltower') 121 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Create'): 122 | logging.info('CREATE CREATE') 123 | logging.info( 124 | 'overriding config recorder for ALL accounts because of first run after function deployment from CloudFormation') 125 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, '', 'Create') 126 | response = {} 127 | ## Send signal back to CloudFormation after the first run 128 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 129 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Update'): 130 | logging.info('Update Update') 131 | logging.info( 132 | 'overriding config recorder for ALL accounts because of overriding config recorder for ALL accounts because of CloudFormation stack update') 133 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, '', 'Update') 134 | response = {} 135 | update_excluded_accounts(selection_mode, excluded_accounts, included_accounts, sqs_url) 136 | 137 | ## Send signal back to CloudFormation after the first run 138 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 139 | elif ('LogicalResourceId' in event) and (event['RequestType'] == 'Delete'): 140 | logging.info('DELETE DELETE') 141 | logging.warning( 142 | 'Initiating config recorder cleanup for ALL accounts due to CloudFormation stack deletion') 143 | override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, '', 'Delete') 144 | response = {} 145 | ## Send signal back to CloudFormation after the final run 146 | cfnresponse.send(event, context, cfnresponse.SUCCESS, response, "CustomResourcePhysicalID") 147 | else: 148 | logging.info("No matching event found") 149 | 150 | logging.info('Execution Successful') 151 | 152 | # TODO implement 153 | return { 154 | 'statusCode': 200 155 | } 156 | 157 | except Exception as e: 158 | exception_type = e.__class__.__name__ 159 | exception_message = str(e) 160 | logging.exception(f'{exception_type}: {exception_message}') 161 | 162 | 163 | def override_config_recorder(selection_mode, excluded_accounts, included_accounts, sqs_url, account, event): 164 | """ 165 | Retrieve Control Tower managed accounts and send SQS messages for processing. 166 | 167 | Args: 168 | selection_mode (str): 'EXCLUSION' or 'INCLUSION' 169 | excluded_accounts (list): Parsed list of excluded account IDs 170 | included_accounts (list): Parsed list of included account IDs 171 | sqs_url (str): SQS queue URL 172 | account (str): Specific account ID to process, or empty string for all accounts 173 | event (str): Event type (e.g., 'Create', 'Update', 'Delete', 'controltower') 174 | """ 175 | try: 176 | client = boto3.client('cloudformation') 177 | # Create a reusable Paginator 178 | paginator = client.get_paginator('list_stack_instances') 179 | 180 | # Create a PageIterator from the Paginator 181 | if account == '': 182 | page_iterator = paginator.paginate(StackSetName='AWSControlTowerBP-BASELINE-CONFIG') 183 | else: 184 | page_iterator = paginator.paginate(StackSetName='AWSControlTowerBP-BASELINE-CONFIG', StackInstanceAccount=account) 185 | 186 | sqs_client = boto3.client('sqs') 187 | 188 | # Process each account returned from Control Tower 189 | for page in page_iterator: 190 | logging.info(page) 191 | 192 | for item in page['Summaries']: 193 | account_id = item['Account'] 194 | region = item['Region'] 195 | # Delegate filtering decision to send_message_to_sqs 196 | send_message_to_sqs( 197 | event, account_id, region, selection_mode, 198 | excluded_accounts, included_accounts, 199 | sqs_client, sqs_url) 200 | 201 | except Exception as e: 202 | exception_type = e.__class__.__name__ 203 | exception_message = str(e) 204 | logging.exception(f'{exception_type}: {exception_message}') 205 | 206 | def send_message_to_sqs(event, account, region, selection_mode, excluded_accounts, included_accounts, sqs_client, sqs_url): 207 | 208 | try: 209 | # Use the new filtering function to determine if account should be processed 210 | if should_process_account(account, selection_mode, excluded_accounts, included_accounts): 211 | # Construct and send SQS message 212 | sqs_msg = f'{{"Account": "{account}", "Region": "{region}", "Event": "{event}"}}' 213 | response = sqs_client.send_message( 214 | QueueUrl=sqs_url, 215 | MessageBody=sqs_msg) 216 | logging.info(f'Message sent to SQS: {sqs_msg}') 217 | # Logging for inclusion/exclusion decisions is handled within should_process_account 218 | 219 | except Exception as e: 220 | exception_type = e.__class__.__name__ 221 | exception_message = str(e) 222 | logging.exception(f'{exception_type}: {exception_message}') 223 | 224 | def update_excluded_accounts(selection_mode, excluded_accounts, included_accounts, sqs_url): 225 | """ 226 | Handle cleanup for accounts when the exclusion list is updated during stack updates. 227 | 228 | This function sends Delete events to accounts that were previously in the exclusion list 229 | but have been removed, allowing them to receive Config Recorder updates again. 230 | 231 | Note: This function is only called during CloudFormation stack updates in EXCLUSION mode. 232 | In INCLUSION mode, no cleanup is needed because accounts not in the inclusion list 233 | simply don't receive messages. 234 | 235 | Args: 236 | selection_mode (str): 'EXCLUSION' or 'INCLUSION' 237 | excluded_accounts (list): Parsed list of excluded account IDs 238 | included_accounts (list): Parsed list of included account IDs (unused in current logic) 239 | sqs_url (str): SQS queue URL 240 | """ 241 | try: 242 | sts_client = boto3.client('sts') 243 | current_account = sts_client.get_caller_identity().get('Account') 244 | 245 | # Create a temporary exclusion list containing only the current account 246 | temp_excluded = [current_account] 247 | 248 | logging.info(f'Current account for cleanup: {current_account}') 249 | 250 | if selection_mode == 'EXCLUSION': 251 | # In exclusion mode, send Delete events to accounts that were previously excluded 252 | # but are no longer in the exclusion list (to restore their Config Recorder settings) 253 | for acct in excluded_accounts: 254 | if acct != current_account: 255 | logging.info(f'Delete request sent for previously excluded account: {acct}') 256 | override_config_recorder( 257 | 'EXCLUSION', temp_excluded, [], 258 | sqs_url, acct, 'Delete') 259 | else: # INCLUSION mode 260 | # In inclusion mode, no cleanup is needed during stack updates 261 | # Accounts not in the inclusion list simply don't receive messages 262 | logging.info('Inclusion mode: no cleanup needed for stack updates') 263 | 264 | except Exception as e: 265 | exception_type = e.__class__.__name__ 266 | exception_message = str(e) 267 | logging.exception(f'{exception_type}: {exception_message}') -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Description: "AWS CloudFormation Template to update config recorder settings in child accounts created by ControlTower." 3 | Parameters: 4 | CloudFormationVersion: 5 | Description: Update Version every time stack is updated to force rerun of the solution. 6 | Type: String 7 | Default: 1 8 | 9 | AccountSelectionMode: 10 | Description: Account selection mode - EXCLUSION (processes all accounts except those in ExcludedAccounts) or INCLUSION (processes only accounts in IncludedAccounts). Default is EXCLUSION for backward compatibility. 11 | Type: String 12 | Default: EXCLUSION 13 | AllowedValues: 14 | - EXCLUSION 15 | - INCLUSION 16 | 17 | ExcludedAccounts: 18 | Description: Excluded Accounts list. This list should contain Management account, Log Archive and Audit accounts at the minimum and other accounts you want to be excluded. Only used when AccountSelectionMode is EXCLUSION. 19 | Type: String 20 | Default: "['111111111111', '222222222222', '333333333333']" 21 | MinLength: 36 22 | MaxLength: 4096 23 | 24 | IncludedAccounts: 25 | Description: List of AWS account IDs to include when AccountSelectionMode is INCLUSION. Only used when AccountSelectionMode is INCLUSION. 26 | Type: String 27 | Default: "['111111111111', '222222222222', '333333333333']" 28 | MinLength: 2 29 | MaxLength: 4096 30 | 31 | ConfigRecorderStrategy: 32 | Default: EXCLUSION 33 | Description: Config Recorder Strategy 34 | Type: String 35 | AllowedValues: 36 | - EXCLUSION 37 | - INCLUSION 38 | 39 | ConfigRecorderExcludedResourceTypes: 40 | Description: List of all resource types to be excluded from Config Recorder if you pick EXCLUSION strategy 41 | Default: "AWS::HealthLake::FHIRDatastore,AWS::Pinpoint::Segment,AWS::Pinpoint::ApplicationSettings" 42 | Type: String 43 | 44 | ConfigRecorderIncludedResourceTypes: 45 | Description: List of all resource types to be included in Config Recorder if you pick INCLUSION strategy 46 | Default: "AWS::S3::Bucket,AWS::CloudTrail::Trail" 47 | Type: String 48 | 49 | ConfigRecorderDailyResourceTypes: 50 | Description: List of all resource types to be set to a daily cadence 51 | Default: "AWS::AutoScaling::AutoScalingGroup,AWS::AutoScaling::LaunchConfiguration" 52 | Type: String 53 | 54 | ConfigRecorderDailyGlobalResourceTypes: 55 | Description: List of Global resource types to be set to a daily cadence in the AWS Control Tower home region. Append to this list as needed. 56 | Default: "AWS::IAM::Policy,AWS::IAM::User,AWS::IAM::Role,AWS::IAM::Group" 57 | Type: String 58 | 59 | ConfigRecorderDefaultRecordingFrequency: 60 | Description: Default Frequency of recording configuration changes. 61 | Default: CONTINUOUS 62 | Type: String 63 | AllowedValues: 64 | - CONTINUOUS 65 | - DAILY 66 | 67 | SourceS3Bucket: 68 | Type: String 69 | Default: marketplace-sa-resources 70 | Description: S3 bucket containing the Lambda deployment packages, leave it default unless you have customized code. 71 | 72 | Metadata: 73 | AWS::CloudFormation::Interface: 74 | ParameterGroups: 75 | - Label: 76 | default: "Mandatory Settings" 77 | Parameters: 78 | - CloudFormationVersion 79 | - SourceS3Bucket 80 | - Label: 81 | default: "Account Selection Settings" 82 | Parameters: 83 | - AccountSelectionMode 84 | - ExcludedAccounts 85 | - IncludedAccounts 86 | - Label: 87 | default: "Recording Strategy Settings" 88 | Parameters: 89 | - ConfigRecorderStrategy 90 | - ConfigRecorderExcludedResourceTypes 91 | - ConfigRecorderIncludedResourceTypes 92 | - Label: 93 | default: " Recording Frequency Settings" 94 | Parameters: 95 | - ConfigRecorderDefaultRecordingFrequency 96 | - ConfigRecorderDailyResourceTypes 97 | - ConfigRecorderDailyGlobalResourceTypes 98 | 99 | Resources: 100 | LambdaZipsBucket: 101 | Type: AWS::S3::Bucket 102 | Properties: 103 | BucketEncryption: 104 | ServerSideEncryptionConfiguration: 105 | - ServerSideEncryptionByDefault: 106 | SSEAlgorithm: AES256 107 | 108 | LambdaZipsBucketPolicy: 109 | Type: AWS::S3::BucketPolicy 110 | Properties: 111 | Bucket: 112 | Ref: LambdaZipsBucket 113 | PolicyDocument: 114 | Statement: 115 | - Effect: Deny 116 | Action: "s3:*" 117 | Principal: "*" 118 | Resource: 119 | - !Sub "arn:aws:s3:::${LambdaZipsBucket}" 120 | - !Sub "arn:aws:s3:::${LambdaZipsBucket}/*" 121 | Condition: 122 | Bool: 123 | aws:SecureTransport: false 124 | 125 | ProducerLambda: 126 | Type: AWS::Lambda::Function 127 | DeletionPolicy: Retain 128 | DependsOn: CopyZips 129 | Properties: 130 | Code: 131 | S3Bucket: !Ref LambdaZipsBucket 132 | S3Key: ct-blogs-content/ct_configrecorder_override_producer.zip 133 | Handler: ct_configrecorder_override_producer.lambda_handler 134 | Role: !GetAtt ProducerLambdaExecutionRole.Arn 135 | Runtime: python3.12 136 | MemorySize: 128 137 | Timeout: 300 138 | Architectures: 139 | - x86_64 140 | ReservedConcurrentExecutions: 1 141 | Environment: 142 | Variables: 143 | ACCOUNT_SELECTION_MODE: !Ref AccountSelectionMode 144 | EXCLUDED_ACCOUNTS: !Ref ExcludedAccounts 145 | INCLUDED_ACCOUNTS: !Ref IncludedAccounts 146 | LOG_LEVEL: INFO 147 | SQS_URL: !Ref SQSConfigRecorder 148 | 149 | ProducerLambdaPermissions: 150 | Type: AWS::Lambda::Permission 151 | DeletionPolicy: Retain 152 | Properties: 153 | Action: "lambda:InvokeFunction" 154 | FunctionName: !Ref ProducerLambda 155 | Principal: "events.amazonaws.com" 156 | SourceArn: !GetAtt ProducerEventTrigger.Arn 157 | 158 | ConsumerLambda: 159 | Type: AWS::Lambda::Function 160 | DeletionPolicy: Retain 161 | DependsOn: CopyZips 162 | Properties: 163 | Code: 164 | S3Bucket: !Ref LambdaZipsBucket 165 | S3Key: ct-blogs-content/ct_configrecorder_override_consumer.zip 166 | Handler: ct_configrecorder_override_consumer.lambda_handler 167 | Role: !GetAtt ConsumerLambdaExecutionRole.Arn 168 | Runtime: python3.12 169 | MemorySize: 128 170 | Timeout: 180 171 | Architectures: 172 | - x86_64 173 | ReservedConcurrentExecutions: 10 174 | Environment: 175 | Variables: 176 | LOG_LEVEL: INFO 177 | CONFIG_RECORDER_STRATEGY: !Ref ConfigRecorderStrategy 178 | CONFIG_RECORDER_OVERRIDE_DAILY_RESOURCE_LIST: !Ref ConfigRecorderDailyResourceTypes 179 | CONFIG_RECORDER_OVERRIDE_DAILY_GLOBAL_RESOURCE_LIST: !Ref ConfigRecorderDailyGlobalResourceTypes 180 | CONFIG_RECORDER_OVERRIDE_EXCLUDED_RESOURCE_LIST: !Ref ConfigRecorderExcludedResourceTypes 181 | CONFIG_RECORDER_OVERRIDE_INCLUDED_RESOURCE_LIST: !Ref ConfigRecorderIncludedResourceTypes 182 | CONFIG_RECORDER_DEFAULT_RECORDING_FREQUENCY: !Ref ConfigRecorderDefaultRecordingFrequency 183 | CONTROL_TOWER_HOME_REGION: !Ref "AWS::Region" 184 | 185 | ConsumerLambdaEventSourceMapping: 186 | Type: AWS::Lambda::EventSourceMapping 187 | DeletionPolicy: Retain 188 | Properties: 189 | BatchSize: 1 190 | Enabled: true 191 | EventSourceArn: !GetAtt SQSConfigRecorder.Arn 192 | FunctionName: !GetAtt ConsumerLambda.Arn 193 | 194 | ProducerLambdaExecutionRole: 195 | Type: "AWS::IAM::Role" 196 | DeletionPolicy: Retain 197 | Properties: 198 | ManagedPolicyArns: 199 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 200 | AssumeRolePolicyDocument: 201 | Version: "2012-10-17" 202 | Statement: 203 | - Effect: Allow 204 | Principal: 205 | Service: 206 | - lambda.amazonaws.com 207 | Action: 208 | - "sts:AssumeRole" 209 | Path: / 210 | Policies: 211 | - PolicyName: ct_cro_producer 212 | PolicyDocument: 213 | Version: "2012-10-17" 214 | Statement: 215 | - Effect: Allow 216 | Action: 217 | - cloudformation:ListStackInstances 218 | Resource: !Sub "arn:${AWS::Partition}:cloudformation:*:*:stackset/AWSControlTowerBP-BASELINE-CONFIG:*" 219 | - Effect: Allow 220 | Action: 221 | - sqs:DeleteMessage 222 | - sqs:ReceiveMessage 223 | - sqs:SendMessage 224 | - sqs:GetQueueAttributes 225 | Resource: !GetAtt SQSConfigRecorder.Arn 226 | 227 | ConsumerLambdaExecutionRole: 228 | Type: "AWS::IAM::Role" 229 | DeletionPolicy: Retain 230 | Properties: 231 | ManagedPolicyArns: 232 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 233 | AssumeRolePolicyDocument: 234 | Version: "2012-10-17" 235 | Statement: 236 | - Effect: Allow 237 | Principal: 238 | Service: 239 | - lambda.amazonaws.com 240 | Action: 241 | - "sts:AssumeRole" 242 | Path: / 243 | Policies: 244 | - PolicyName: policy-sts-all 245 | PolicyDocument: 246 | Version: "2012-10-17" 247 | Statement: 248 | - Effect: Allow 249 | Action: 250 | - sts:AssumeRole 251 | Resource: "*" 252 | - Effect: Allow 253 | Action: 254 | - sqs:DeleteMessage 255 | - sqs:ReceiveMessage 256 | - sqs:SendMessage 257 | - sqs:GetQueueAttributes 258 | Resource: !GetAtt SQSConfigRecorder.Arn 259 | 260 | SQSConfigRecorder: 261 | Type: AWS::SQS::Queue 262 | DeletionPolicy: Retain 263 | Properties: 264 | VisibilityTimeout: 180 265 | DelaySeconds: 5 266 | KmsMasterKeyId: alias/aws/sqs 267 | 268 | ProducerEventTrigger: 269 | Type: AWS::Events::Rule 270 | Properties: 271 | Description: "Rule to trigger config recorder override producer lambda" 272 | EventBusName: default 273 | EventPattern: '{ 274 | "source": ["aws.controltower"], 275 | "detail-type": ["AWS Service Event via CloudTrail"], 276 | "detail": { 277 | "eventName": ["UpdateLandingZone", "CreateManagedAccount", "UpdateManagedAccount", "ResetLandingZone"] 278 | } 279 | }' 280 | Name: !GetAtt SQSConfigRecorder.QueueName 281 | State: ENABLED 282 | Targets: 283 | - Arn: 284 | Fn::GetAtt: 285 | - "ProducerLambda" 286 | - "Arn" 287 | Id: "ProducerTarget" 288 | 289 | ProducerLambdaTrigger: 290 | Type: "Custom::ExecuteLambda" 291 | Properties: 292 | ServiceToken: !GetAtt "ProducerLambda.Arn" 293 | FunctionName: !Ref ProducerLambda 294 | Version: !Ref CloudFormationVersion 295 | 296 | CopyZips: 297 | Type: Custom::CopyZips 298 | Properties: 299 | ServiceToken: !GetAtt "CopyZipsFunction.Arn" 300 | DestBucket: !Ref "LambdaZipsBucket" 301 | SourceBucket: !Ref SourceS3Bucket 302 | Prefix: ct-blogs-content/ 303 | Objects: 304 | - "ct_configrecorder_override_producer.zip" 305 | - "ct_configrecorder_override_consumer.zip" 306 | 307 | CopyZipsRole: 308 | Type: AWS::IAM::Role 309 | Properties: 310 | AssumeRolePolicyDocument: 311 | Version: "2012-10-17" 312 | Statement: 313 | - Effect: Allow 314 | Principal: 315 | Service: lambda.amazonaws.com 316 | Action: sts:AssumeRole 317 | ManagedPolicyArns: 318 | - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 319 | Path: / 320 | Policies: 321 | - PolicyName: lambda-copier 322 | PolicyDocument: 323 | Version: "2012-10-17" 324 | Statement: 325 | - Effect: Allow 326 | Action: 327 | - s3:GetObject 328 | - s3:GetObjectTagging 329 | Resource: !Sub "arn:${AWS::Partition}:s3:::${SourceS3Bucket}/ct-blogs-content/*" 330 | - Effect: Allow 331 | Action: 332 | - s3:PutObject 333 | - s3:DeleteObject 334 | - s3:PutObjectTagging 335 | Resource: 336 | - !Sub "arn:${AWS::Partition}:s3:::${LambdaZipsBucket}/ct-blogs-content/*" 337 | 338 | CopyZipsFunction: 339 | Type: AWS::Lambda::Function 340 | Properties: 341 | Description: Copies objects from the S3 bucket to a new location. 342 | Handler: index.handler 343 | Runtime: python3.12 344 | Role: !GetAtt "CopyZipsRole.Arn" 345 | ReservedConcurrentExecutions: 1 346 | Timeout: 300 347 | Code: 348 | ZipFile: | 349 | import json 350 | import logging 351 | import threading 352 | import boto3 353 | import cfnresponse 354 | def copy_objects(source_bucket, dest_bucket, prefix, objects): 355 | s3 = boto3.client('s3') 356 | for o in objects: 357 | key = prefix + o 358 | copy_source = { 359 | 'Bucket': source_bucket, 360 | 'Key': key 361 | } 362 | print('copy_source: %s' % copy_source) 363 | print('dest_bucket = %s'%dest_bucket) 364 | print('key = %s' %key) 365 | s3.copy_object(CopySource=copy_source, Bucket=dest_bucket, 366 | Key=key) 367 | def delete_objects(bucket, prefix, objects): 368 | s3 = boto3.client('s3') 369 | objects = {'Objects': [{'Key': prefix + o} for o in objects]} 370 | s3.delete_objects(Bucket=bucket, Delete=objects) 371 | def timeout(event, context): 372 | logging.error('Execution is about to time out, sending failure response to CloudFormation') 373 | cfnresponse.send(event, context, cfnresponse.FAILED, {}, None) 374 | def handler(event, context): 375 | # make sure we send a failure to CloudFormation if the function 376 | # is going to timeout 377 | timer = threading.Timer((context.get_remaining_time_in_millis() 378 | / 1000.00) - 0.5, timeout, args=[event, context]) 379 | timer.start() 380 | print('Received event: %s' % json.dumps(event)) 381 | status = cfnresponse.SUCCESS 382 | try: 383 | source_bucket = event['ResourceProperties']['SourceBucket'] 384 | dest_bucket = event['ResourceProperties']['DestBucket'] 385 | prefix = event['ResourceProperties']['Prefix'] 386 | objects = event['ResourceProperties']['Objects'] 387 | if event['RequestType'] == 'Delete': 388 | delete_objects(dest_bucket, prefix, objects) 389 | else: 390 | copy_objects(source_bucket, dest_bucket, prefix, objects) 391 | except Exception as e: 392 | logging.error('Exception: %s' % e, exc_info=True) 393 | status = cfnresponse.FAILED 394 | finally: 395 | timer.cancel() 396 | cfnresponse.send(event, context, status, {}, None) 397 | --------------------------------------------------------------------------------