diff --git a/CHANGELOG.md b/CHANGELOG.md index b09e856..b852d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.8.1] - 2024-12-09 +- Add support for Resource Control Policies (RCPs). RCPs help you ensure that resources in your accounts stay within + your organization’s access control guidelines. Learn more [here](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_rcps.html). + - To set up a configuration package for Resource Control Polices, see + [CfCT customization guide](https://docs.aws.amazon.com/controltower/latest/userguide/cfct-customizations-dev-guide.html). +- Add support for GitHub as a version control system (VCS) alternative for CfCT. ([#21](https://github.com/aws-solutions/aws-control-tower-customizations/issues/21)) + - Learn more on how to set up CfCT using GitHub in the [Set up GitHub as the configuration source](https://docs.aws.amazon.com/controltower/latest/userguide/cfct-github-configuration-source.html) + section of the user guide. +- Add guidance on CodeCommit availability to new customers. + ## [2.7.3] - 2024-09-13 - Update dependencies - `PyYAML` 5.4.1 ([#154](https://github.com/aws-solutions/aws-control-tower-customizations/issues/154), [#169](https://github.com/aws-solutions/aws-control-tower-customizations/issues/169)) @@ -19,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade botocore to version 1.31.17 and boto3 to version 1.28.17 ## [2.7.1] - 2024-05-30 -* Update dependencies & runtimes ([#186]((https://github.com/aws-solutions/aws-control-tower-customizations/issues/186)), [#193]((https://github.com/aws-solutions/aws-control-tower-customizations/issues/193))) +* Update dependencies & runtimes ([#186](https://github.com/aws-solutions/aws-control-tower-customizations/issues/186), [#193](https://github.com/aws-solutions/aws-control-tower-customizations/issues/193)) * Building the solution from source now requires Python 3.11 or higher * Update Python Lambda runtimes to 3.11 * Update Ruby version to 3.3 @@ -140,4 +150,4 @@ in the input account list has an existing stack instance. ## [1.0.0] - 2020-01-10 ### Added -- Initial public release \ No newline at end of file +- Initial public release diff --git a/README.md b/README.md index 7dd89b9..3ab0876 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ ## Customizations for AWS Control Tower Solution -The Customizations for AWS Control Tower solution combines AWS Control Tower and other highly-available, trusted AWS services to help customers more quickly set up a secure, multi-account AWS environment based on AWS best practices. Customers can easily add customizations to their AWS Control Tower landing zone using an AWS CloudFormation template and service control policies (SCPs). Customers can deploy their custom template and policies to both individual accounts and organizational units (OUs) within their organization. Customizations for AWS Control Tower integrates with AWS Control Tower lifecycle events to ensure that resource deployments stay in sync with the customer's landing zone. For example, when a new account is created using the AWS Control Tower account factory, the solution ensures that all resources attached to the account's OUs will be automatically deployed. Before deploying this solution, customers need to have an AWS Control Tower landing zone deployed in their account. +The Customizations for AWS Control Tower solution combines AWS Control Tower and other highly-available, trusted AWS services to help customers more quickly set up a secure, multi-account AWS environment based on AWS best practices. Customers can easily add customizations to their AWS Control Tower landing zone using an AWS CloudFormation template, service control policies (SCPs), and resource control policies (RCPs). Customers can deploy their custom template and policies to both individual accounts and organizational units (OUs) within their organization. Customizations for AWS Control Tower integrates with AWS Control Tower lifecycle events to ensure that resource deployments stay in sync with the customer's landing zone. For example, when a new account is created using the AWS Control Tower account factory, the solution ensures that all resources attached to the account's OUs will be automatically deployed. Before deploying this solution, customers need to have an AWS Control Tower landing zone deployed in their account. ## Getting Started To get started with Customizations for AWS Control Tower, please review the [documentation](https://docs.aws.amazon.com/controltower/latest/userguide/customize-landing-zone.html) -## Running unit tests for customization +## Running unit tests for customization * Clone the repository, then make the desired code changes * Next, run unit tests to make sure added customization passes the tests @@ -53,11 +53,10 @@ chmod +x ./deployment/build-s3-dist.sh * Get the link of the custom-control-tower-initiation.template loaded to your Amazon S3 bucket. * Deploy the Customizations for AWS Control Tower solution to your account by launching a new AWS CloudFormation stack using the link of the custom-control-tower-initiation.template. - ## Collection of operational metrics This solution collects anonymous operational metrics to help AWS improve the quality and features of the solution. For more information, including how to disable this capability, please see the [documentation here](https://docs.aws.amazon.com/controltower/latest/userguide/cfct-metrics.html). ## License -See license [here](https://github.com/aws-solutions/aws-control-tower-customizations/blob/main/LICENSE.txt) \ No newline at end of file +See license [here](https://github.com/aws-solutions/aws-control-tower-customizations/blob/main/LICENSE.txt) diff --git a/VERSION b/VERSION index c9c156a..30505b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.7.3 +v2.8.1 diff --git a/customizations-for-aws-control-tower.template b/customizations-for-aws-control-tower.template index 9098577..df3f2b3 100644 --- a/customizations-for-aws-control-tower.template +++ b/customizations-for-aws-control-tower.template @@ -12,7 +12,7 @@ # permissions and limitations under the License. AWSTemplateFormatVersion: '2010-09-09' -Description: '(SO0089) - customizations-for-aws-control-tower Solution. Version: v2.7.3' +Description: '(SO0089) - customizations-for-aws-control-tower Solution. Version: v2.8.1' Parameters: PipelineApprovalStage: @@ -32,6 +32,7 @@ Parameters: AllowedValues: - 'Amazon S3' - 'AWS CodeCommit' + - 'GitHub (via Code Connection)' Default: 'Amazon S3' Type: String @@ -54,6 +55,29 @@ Parameters: - 'Yes' - 'No' + CodeConnection: + Description: Resource ARN for the Code Connection to use + Default: '' + Type: String + AllowedPattern: '(?!.*\s)|(arn:aws(-[\w]+)*:.+:.+:[0-9]{12}:.+)' + + GitHubOwnerName: + Description: The user/organization that owns the GitHub repository + Default: git-username + Type: String + AllowedPattern: '(?!.*\s)|(^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$)' + + GitHubRepositoryName: + Description: The name of the GitHub repository that contains custom Control Tower configuration. The suffix .git is prohibited. + Default: custom-control-tower-configuration + Type: String + AllowedPattern: '^[\w\.-]+' + + GitHubBranchName: + Description: Name of the branch in GitHub repository that contains custom Control Tower configuration. + Default: main + Type: String + RegionConcurrencyType: Description: Select the the concurrency type of deploying StackSets operations in Regions. Default: 'PARALLEL' @@ -100,6 +124,13 @@ Metadata: - ExistingRepository - CodeCommitRepositoryName - CodeCommitBranchName + - Label: + default: GitHub Setup (Applicable if 'GitHub (via Code Connection)' was selected as the CodePipeline Source) + Parameters: + - CodeConnection + - GitHubOwnerName + - GitHubRepositoryName + - GitHubBranchName - Label: default: AWS CloudFormation StackSets Configuration Parameters: @@ -126,6 +157,14 @@ Metadata: default: Max Concurrent Percentage FailureTolerancePercentage: default: Failure Tolerance Percentage + CodeConnection: + default: ARN of the Code Connection + GitHubOwnerName: + default: GitHub User or Organization + GitHubRepositoryName: + default: GitHub Repository Name + GitHubBranchName: + default: GitHub Branch Name Mappings: BucketConfiguration: @@ -167,10 +206,38 @@ Conditions: IsPipelineApprovalStageCondition: !Equals [!Ref PipelineApprovalStage, 'Yes'] IsBuildCustomControlTowerCondition: !Equals [!FindInMap [AutoBuild, CustomControlTower, Flag], 'Yes'] IsCodeCommitPipelineSource: !Equals [!Ref CodePipelineSource, 'AWS CodeCommit'] + IsGitHubPipelineSource: !Equals [!Ref CodePipelineSource, 'GitHub (via Code Connection)'] IsS3PipelineSource: !Equals [!Ref CodePipelineSource, "Amazon S3"] IsExistingRepository: !Equals [!Ref ExistingRepository, 'Yes'] IsNewCodeCommitRepository: !And [!Not [!Condition IsExistingRepository], !Condition IsCodeCommitPipelineSource] +Rules: + GitHubPipelineSource: + RuleCondition: !Equals + - !Ref CodePipelineSource + - 'GitHub (via Code Connection)' + Assertions: + - Assert: !Not + - !Equals + - !Ref CodeConnection + - "" + AsertDescription: "The ARN of a Code Connection is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubOwnerName + - "" + AsertDescription: "The Owner (User or Organization) for the GitHub repoitory is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubRepositoryName + - "" + AsertDescription: "The repository name for the GitHub repository is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubBranchName + - "" + AsertDescription: "The branch name for the GitHub repository is required for a deployment using GitHub as its source" + Resources: PipelineApprovalTopic: @@ -346,7 +413,7 @@ Resources: BranchName: !Ref CodeCommitBranchName S3: Bucket: !Sub control-tower-cfct-assets-prod-${AWS::Region} - Key: !Sub customizations-for-aws-control-tower/v2.7.3/custom-control-tower-configuration-${AWS::Region}.zip + Key: !Sub customizations-for-aws-control-tower/v2.8.1/custom-control-tower-configuration-${AWS::Region}.zip # SSM Parameter to store the git repository name CustomControlTowerRepoNameParameter: @@ -429,6 +496,7 @@ Resources: Resource: - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${CustomControlTowerCodeBuild} - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${SCPCodeBuild} + - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${RCPCodeBuild} - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${StackSetCodeBuild} - Effect: "Allow" Action: @@ -450,6 +518,17 @@ Resources: - "sns:Publish" Resource: !Ref PipelineApprovalTopic - !Ref AWS::NoValue + - !If + - IsGitHubPipelineSource + - Effect: "Allow" + Action: + - 'codestar-connections:UseConnection' + - 'codestar-connections:GetConnection' + - 'codestar-connections:ListConnections' + - 'codestar-connections:ListTagsForResource' + Resource: !Ref CodeConnection + - !Ref AWS::NoValue + CustomControlTowerCodePipeline: Type: AWS::CodePipeline::Pipeline @@ -464,24 +543,24 @@ Resources: Actions: - Name: Source ActionTypeId: - !If - - IsCodeCommitPipelineSource - - Category: Source - Owner: AWS - Version: "1" - Provider: CodeCommit - - Category: Source - Owner: AWS - Version: "1" - Provider: S3 + Category: Source + Owner: AWS + Version: 1 + Provider: !If [ IsCodeCommitPipelineSource, CodeCommit, !If [ IsGitHubPipelineSource, CodeStarSourceConnection, S3]] OutputArtifacts: - Name: SourceApp Configuration: !If - - IsCodeCommitPipelineSource - - RepositoryName: !Ref CodeCommitRepositoryName - BranchName: !Ref CodeCommitBranchName - PollForSourceChanges: false + - IsCodeCommitPipelineSource + - RepositoryName: !Ref CodeCommitRepositoryName + BranchName: !Ref CodeCommitBranchName + PollForSourceChanges: false + - !If + - IsGitHubPipelineSource + - ConnectionArn: !Ref CodeConnection + FullRepositoryId: !Sub "${GitHubOwnerName}/${GitHubRepositoryName}" + BranchName: !Ref GitHubBranchName + DetectChanges: true - S3Bucket: !Ref CustomControlTowerPipelineS3Bucket S3ObjectKey: !FindInMap [BucketConfiguration, CustomControlTowerPipelineS3TriggerKey, Name] PollForSourceChanges: false @@ -526,6 +605,18 @@ Resources: Provider: CodeBuild Configuration: ProjectName: !Ref SCPCodeBuild + - Name: ResourceControlPolicy + Actions: + - Name: CodeBuild + InputArtifacts: + - Name: BuiltApp + ActionTypeId: + Category: Build + Owner: AWS + Version: "1" + Provider: CodeBuild + Configuration: + ProjectName: !Ref RCPCodeBuild - Name: CloudformationResource Actions: - Name: CodeBuild @@ -607,7 +698,7 @@ Resources: - {KMSKeyName: !FindInMap [KMS, Alias, Name]} Source: Type: CODEPIPELINE - BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1>/dev/null\n - export LC_ALL='en_US.UTF-8'\n - locale-gen en_US en_US.UTF-8\n - dpkg-reconfigure locales --frontend noninteractive\n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.7.3/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES \n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n\n" + BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1>/dev/null\n - export LC_ALL='en_US.UTF-8'\n - locale-gen en_US en_US.UTF-8\n - dpkg-reconfigure locales --frontend noninteractive\n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.8.1/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES \n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n\n" Environment: ComputeType: BUILD_GENERAL1_SMALL Image: "aws/codebuild/standard:7.0" @@ -632,7 +723,7 @@ Resources: - Name: SOLUTION_ID Value: !FindInMap [ Solution, Metrics, SolutionID ] - Name: SOLUTION_VERSION - Value: v2.7.3 + Value: v2.8.1 - Name: AWS_STS_REGIONAL_ENDPOINTS Value: "regional" Artifacts: @@ -737,7 +828,7 @@ Resources: - {KMSKeyName: !FindInMap [KMS, Alias, Name]} Source: Type: CODEPIPELINE - BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null \n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.7.3/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" + BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null \n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.8.1/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" Environment: ComputeType: BUILD_GENERAL1_SMALL Image: "aws/codebuild/standard:7.0" @@ -758,7 +849,134 @@ Resources: - Name: SOLUTION_ID Value: !FindInMap [ Solution, Metrics, SolutionID ] - Name: SOLUTION_VERSION - Value: v2.7.3 + Value: v2.8.1 + - Name: AWS_STS_REGIONAL_ENDPOINTS + Value: "regional" + Artifacts: + Name: !Sub ${CustomControlTowerPipelineArtifactS3Bucket}-Built + Type: CODEPIPELINE + TimeoutInMinutes: 60 + + RCPCodeBuildRole: + Type: "AWS::IAM::Role" + Metadata: + cfn_nag: + rules_to_suppress: + - id: W11 + reason: "Allow * for Organizations APIs to list/describe/move user created child accounts in the AWS Organizations" + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "codebuild.amazonaws.com" + Action: + - "sts:AssumeRole" + Path: "/" + Policies: + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-Logs" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/* + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-S3" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - s3:GetObject + - s3:PutObject + Resource: + - !Sub arn:${AWS::Partition}:s3:::${CustomControlTowerPipelineArtifactS3Bucket}/* + - Effect: "Allow" + Action: + - s3:GetObject + Resource: + - !Sub arn:${AWS::Partition}:s3:::*/* # needed to support validation of remotely sourced templates feature. The host S3 bucket can be created by the customers or partners. + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-StepFunctions" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - states:ListExecutions + - states:StartExecution + - states:StopExecution + - states:DescribeStateMachine + Resource: + - !Ref ResourceControlPolicyMachine + - Effect: Allow + Action: + - states:DescribeStateMachineForExecution + - states:DescribeExecution + Resource: + - !Sub arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:execution:${ResourceControlPolicyMachine.Name}:* + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-Organizations" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - organizations:ListRoots + - organizations:ListOrganizationalUnitsForParent + - organizations:ListAccountsForParent + Resource: '*' # The APIs above only support '*' resource. + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-SSM" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParametersByPath + Resource: !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/* + - Effect: Allow + Action: + - ssm:DescribeParameters + Resource: '*' # The APIs above only support '*' resource. + + RCPCodeBuild: + Type: AWS::CodeBuild::Project + DependsOn: CustomControlTowerDeploymentLambda + Properties: + Name: Custom-Control-Tower-RCP-CodeBuild + ServiceRole: !GetAtt RCPCodeBuildRole.Arn + EncryptionKey: !Sub + - alias/${KMSKeyName} + - {KMSKeyName: !FindInMap [KMS, Alias, Name]} + Source: + Type: CODEPIPELINE + BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null \n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.8.1/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" + Environment: + ComputeType: BUILD_GENERAL1_SMALL + Image: "aws/codebuild/standard:7.0" + Type: LINUX_CONTAINER + EnvironmentVariables: + - Name: SM_ARN + Value: !Ref ResourceControlPolicyMachine + - Name: LOG_LEVEL + Value: !FindInMap [LambdaFunction, Logging, Level] + - Name: WAIT_TIME + Value: "15" + - Name: STAGE_NAME + Value: "rcp" + - Name: ARTIFACT_BUCKET + Value: !Ref CustomControlTowerPipelineArtifactS3Bucket + - Name: KMS_KEY_ALIAS_NAME + Value: !FindInMap [KMS, Alias, Name] + - Name: SOLUTION_ID + Value: !FindInMap [ Solution, Metrics, SolutionID ] + - Name: SOLUTION_VERSION + Value: v2.8.1 - Name: AWS_STS_REGIONAL_ENDPOINTS Value: "regional" Artifacts: @@ -915,7 +1133,7 @@ Resources: - {KMSKeyName: !FindInMap [KMS, Alias, Name]} Source: Type: CODEPIPELINE - BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null\n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.7.3/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" + BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null\n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://control-tower-cfct-assets-prod-${AWS_REGION}/customizations-for-aws-control-tower/v2.8.1/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" Environment: ComputeType: BUILD_GENERAL1_SMALL Image: "aws/codebuild/standard:7.0" @@ -940,7 +1158,7 @@ Resources: - Name: SOLUTION_ID Value: !FindInMap [Solution, Metrics, SolutionID] - Name: SOLUTION_VERSION - Value: v2.7.3 + Value: v2.8.1 - Name: METRICS_URL Value: !FindInMap [Solution, Metrics, MetricsURL] - Name: CONTROL_TOWER_BASELINE_CONFIG_STACKSET @@ -1066,10 +1284,10 @@ Resources: Variables: LOG_LEVEL: !FindInMap [LambdaFunction, Logging, Level] SOLUTION_ID: !FindInMap [Solution, Metrics, SolutionID] - SOLUTION_VERSION: v2.7.3 + SOLUTION_VERSION: v2.8.1 Code: S3Bucket: !Sub "control-tower-cfct-assets-prod-${AWS::Region}" - S3Key: customizations-for-aws-control-tower/v2.7.3/custom-control-tower-config-deployer.zip + S3Key: customizations-for-aws-control-tower/v2.8.1/custom-control-tower-config-deployer.zip FunctionName: CustomControlTowerDeploymentLambda Description: Custom Control Tower Deployment Lambda Handler: config_deployer.lambda_handler @@ -1088,7 +1306,7 @@ Resources: DestinationBucketName: !Ref CustomControlTowerPipelineS3Bucket DestinationS3Key: !If [IsBuildCustomControlTowerCondition, !FindInMap [BucketConfiguration, CustomControlTowerPipelineS3TriggerKey, Name], !FindInMap [BucketConfiguration, CustomControlTowerPipelineS3NonTriggerKey, Name]] SourceBucketName: !Sub control-tower-cfct-assets-prod-${AWS::Region} - SourceS3Key: customizations-for-aws-control-tower/v2.7.3/custom-control-tower-configuration.zip + SourceS3Key: customizations-for-aws-control-tower/v2.8.1/custom-control-tower-configuration.zip KMSConfig: KMSKeyAlias: !Sub - alias/${KMSKeyName} @@ -1126,6 +1344,7 @@ Resources: - Fn::Sub: ${CustomControlTowerCodePipelineRole.Arn} - Fn::Sub: ${CustomControlTowerCodeBuildRole.Arn} - Fn::Sub: ${SCPCodeBuildRole.Arn} + - Fn::Sub: ${RCPCodeBuildRole.Arn} - Fn::Sub: ${StackSetCodeBuildRole.Arn} - Fn::Sub: ${CustomControlTowerLELambdaRole.Arn} Service: @@ -1336,14 +1555,14 @@ Resources: ADMINISTRATION_ROLE_ARN: !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:role/service-role/AWSControlTowerStackSetRole EXECUTION_ROLE_NAME: !FindInMap [AWSControlTower, ExecutionRole, Name] SOLUTION_ID: !FindInMap [Solution, Metrics, SolutionID] - SOLUTION_VERSION: v2.7.3 + SOLUTION_VERSION: v2.8.1 METRICS_URL: !FindInMap [Solution, Metrics, MetricsURL] MAX_CONCURRENT_PERCENT: !Ref MaxConcurrentPercentage FAILED_TOLERANCE_PERCENT: !Ref FailureTolerancePercentage REGION_CONCURRENCY_TYPE: !Ref RegionConcurrencyType Code: S3Bucket: !Sub "control-tower-cfct-assets-prod-${AWS::Region}" - S3Key: customizations-for-aws-control-tower/v2.7.3/custom-control-tower-state-machine.zip + S3Key: customizations-for-aws-control-tower/v2.8.1/custom-control-tower-state-machine.zip FunctionName: CustomControlTowerStateMachineLambda Description: Custom Control Tower State Machine Handler Handler: state_machine_router.lambda_handler @@ -1929,6 +2148,557 @@ Resources: } } + ResourceControlPolicyMachine: + Type: 'AWS::StepFunctions::StateMachine' + Properties: + StateMachineName: CustomControlTowerResourceControlPolicyMachine + RoleArn: !GetAtt 'StateMachineRole.Arn' + DefinitionString: + Fn::Sub: |- + { + "Comment": "A state machine that manages the Resource Control Policies.", + "StartAt": "Metrics Pass", + "States": { + "Metrics Pass": { + "Type": "Pass", + "Result": { + "ClassName": "StackSetSMRequests", + "FunctionName": "send_execution_data" + }, + "ResultPath": "$.params", + "Next": "Metrics" + }, + "Metrics": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Create/Delete or Attach/Detach Policy?" + }, + "Create/Delete or Attach/Detach Policy?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.ResourceProperties.AccountId", + "StringEquals": "", + "Next": "Enable Policy Type params" + }, + { + "Variable": "$.ResourceProperties.AccountId", + "StringGreaterThan": "", + "Next": "Attach/Detach Policy params" + } + ] + }, + "Enable Policy Type params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "enable_policy_type" + }, + "ResultPath": "$.params", + "Next": "Enable Policy Type" + }, + "Enable Policy Type": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Wait" + }, + "Wait": { + "Type": "Wait", + "Seconds": 10, + "Next": "Create/Delete Policy params" + }, + "Create/Delete Policy params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies" + }, + "ResultPath": "$.params", + "Next": "Check If Policy Exist?" + }, + "Check If Policy Exist?": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Create or Delete Policy?" + }, + "Create or Delete Policy?": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "no" + } + ], + "Next": "Create Policy Params" + }, + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "yes" + } + ], + "Next": "Update Policy Params" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "yes" + } + ], + "Next": "Detach Policy from All Accounts Params" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "no" + } + ], + "Next": "Finish" + } + ] + }, + "Create Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "create_policy" + }, + "ResultPath": "$.params", + "Next": "Create Policy" + }, + "Create Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "ConfigureCount2 params" + }, + "Update Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "update_policy" + }, + "ResultPath": "$.params", + "Next": "Update Policy" + }, + "Update Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "ConfigureCount2 params" + }, + "ConfigureCount2 params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "configure_count_2" + }, + "ResultPath": "$.params", + "Next": "ConfigureCount2" + }, + "ConfigureCount2": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Iterator2 params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "iterator2" + }, + "ResultPath": "$.params", + "Next": "Iterator2" + }, + "Iterator2": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "Next": "IsCountReached2" + }, + "IsCountReached2": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Continue", + "BooleanEquals": true, + "Next": "List Policies For OU Params" + } + ], + "Default": "Finish" + }, + "List Policies For OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies_for_ou" + }, + "ResultPath": "$.params", + "Next": "List Policies For OU" + }, + "List Policies For OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Attach or Detach Policy to OU Choice" + }, + "Attach or Detach Policy to OU Choice": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Operation", + "StringEquals": "Attach", + "Next": "Check if Policy is attached to OU?" + }, + { + "Variable": "$.Operation", + "StringEquals": "Detach", + "Next": "Check if Policy is detached from OU?" + } + ], + "Default": "Invalid Operation2" + }, + "Invalid Operation2": { + "Type": "Fail", + "Cause": "Invalid Operation Type, valid choices are [Attach, Detach]", + "Error": "Returning NULL in the response." + }, + "Check if Policy is attached to OU?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Iterator2 params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Attach Policy to OU Params" + } + ], + "Default": "Invalid Operation2" + }, + "Attach Policy to OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "attach_policy" + }, + "ResultPath": "$.params", + "Next": "Attach Policy to OU" + }, + "Attach Policy to OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Check if Policy is detached from OU?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Detach Policy from OU Params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Iterator2 params" + } + ], + "Default": "Invalid Operation2" + }, + "Detach Policy from OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy" + }, + "ResultPath": "$.params", + "Next": "Detach Policy from OU" + }, + "Detach Policy from OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Detach Policy from All Accounts Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy_from_all_accounts" + }, + "ResultPath": "$.params", + "Next": "Detach Policy from All Accounts" + }, + "Detach Policy from All Accounts": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Delete Policy Params" + }, + "Delete Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "delete_policy" + }, + "ResultPath": "$.params", + "Next": "Delete Policy" + }, + "Delete Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Finish" + }, + "Attach/Detach Policy params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "configure_count" + }, + "ResultPath": "$.params", + "Next": "ConfigureCount" + }, + "ConfigureCount": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Iterator params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "iterator" + }, + "ResultPath": "$.params", + "Next": "Iterator" + }, + "Iterator": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "Next": "IsCountReached" + }, + "IsCountReached": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Continue", + "BooleanEquals": true, + "Next": "List Policy Params" + } + ], + "Default": "Finish" + }, + "List Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies" + }, + "ResultPath": "$.params", + "Next": "List Policy" + }, + "List Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "List Policies For Account Params" + }, + "List Policies For Account Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies_for_account" + }, + "ResultPath": "$.params", + "Next": "List Policies For Account" + }, + "List Policies For Account": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Attach or Detach Policy Choice" + }, + "Attach or Detach Policy Choice": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Attach" + } + ], + "Next": "Check if Policy is attached?" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Attach" + } + ], + "Next": "Check if Policy is detached?" + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Detach", + "Next": "Check if Policy is detached?" + } + ], + "Default": "Invalid Operation" + }, + "Invalid Operation": { + "Type": "Fail", + "Cause": "Invalid Operation Type, valid choices are [Attach, Detach]", + "Error": "Returning NULL in the response." + }, + "Check if Policy is attached?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Iterator params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Attach Policy Params" + } + ], + "Default": "Invalid Operation" + }, + "Attach Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "attach_policy" + }, + "ResultPath": "$.params", + "Next": "Attach Policy" + }, + "Attach Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Check if Policy is detached?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Detach Policy Params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Iterator params" + } + ], + "Default": "Invalid Operation" + }, + "Detach Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy" + }, + "ResultPath": "$.params", + "Next": "Detach Policy" + }, + "Detach Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Finish": { + "Type": "Succeed" + } + } + } + StackSetStateMachine: Type: 'AWS::StepFunctions::StateMachine' Properties: @@ -2935,10 +3705,10 @@ Resources: LOG_LEVEL: !FindInMap [LambdaFunction, Logging, Level] CODE_PIPELINE_NAME: !Ref CustomControlTowerCodePipeline SOLUTION_ID: !FindInMap [ Solution, Metrics, SolutionID ] - SOLUTION_VERSION: v2.7.3 + SOLUTION_VERSION: v2.8.1 Code: S3Bucket: !Sub "control-tower-cfct-assets-prod-${AWS::Region}" - S3Key: customizations-for-aws-control-tower/v2.7.3/custom-control-tower-lifecycle-event-handler.zip + S3Key: customizations-for-aws-control-tower/v2.8.1/custom-control-tower-lifecycle-event-handler.zip Description: Custom Control Tower Lifecyle event Lambda to handle lifecycle events Handler: lifecycle_event_handler.lambda_handler MemorySize: 512 @@ -3245,6 +4015,6 @@ Outputs: Value: !Ref CustomControlTowerPipelineS3Bucket CustomControlTowerSolutionVersion: Description: Version Number - Value: "v2.7.3" + Value: "v2.8.1" Export: Name: Custom-Control-Tower-Version diff --git a/deployment/custom-control-tower-initiation.template b/deployment/custom-control-tower-initiation.template index 2786f18..233ce7c 100644 --- a/deployment/custom-control-tower-initiation.template +++ b/deployment/custom-control-tower-initiation.template @@ -32,6 +32,7 @@ Parameters: AllowedValues: - 'Amazon S3' - 'AWS CodeCommit' + - 'GitHub (via Code Connection)' Default: 'Amazon S3' Type: String @@ -54,6 +55,29 @@ Parameters: - 'Yes' - 'No' + CodeConnection: + Description: Resource ARN for the Code Connection to use + Default: '' + Type: String + AllowedPattern: '(?!.*\s)|(arn:aws(-[\w]+)*:.+:.+:[0-9]{12}:.+)' + + GitHubOwnerName: + Description: The user/organization that owns the GitHub repository + Default: git-username + Type: String + AllowedPattern: '(?!.*\s)|(^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$)' + + GitHubRepositoryName: + Description: The name of the GitHub repository that contains custom Control Tower configuration. The suffix .git is prohibited. + Default: custom-control-tower-configuration + Type: String + AllowedPattern: '^[\w\.-]+' + + GitHubBranchName: + Description: Name of the branch in GitHub repository that contains custom Control Tower configuration. + Default: main + Type: String + RegionConcurrencyType: Description: Select the the concurrency type of deploying StackSets operations in Regions. Default: 'PARALLEL' @@ -100,6 +124,13 @@ Metadata: - ExistingRepository - CodeCommitRepositoryName - CodeCommitBranchName + - Label: + default: GitHub Setup (Applicable if 'GitHub (via Code Connection)' was selected as the CodePipeline Source) + Parameters: + - CodeConnection + - GitHubOwnerName + - GitHubRepositoryName + - GitHubBranchName - Label: default: AWS CloudFormation StackSets Configuration Parameters: @@ -126,6 +157,14 @@ Metadata: default: Max Concurrent Percentage FailureTolerancePercentage: default: Failure Tolerance Percentage + CodeConnection: + default: ARN of the Code Connection + GitHubOwnerName: + default: GitHub User or Organization + GitHubRepositoryName: + default: GitHub Repository Name + GitHubBranchName: + default: GitHub Branch Name Mappings: BucketConfiguration: @@ -167,10 +206,38 @@ Conditions: IsPipelineApprovalStageCondition: !Equals [!Ref PipelineApprovalStage, 'Yes'] IsBuildCustomControlTowerCondition: !Equals [!FindInMap [AutoBuild, CustomControlTower, Flag], 'Yes'] IsCodeCommitPipelineSource: !Equals [!Ref CodePipelineSource, 'AWS CodeCommit'] + IsGitHubPipelineSource: !Equals [!Ref CodePipelineSource, 'GitHub (via Code Connection)'] IsS3PipelineSource: !Equals [!Ref CodePipelineSource, "Amazon S3"] IsExistingRepository: !Equals [!Ref ExistingRepository, 'Yes'] IsNewCodeCommitRepository: !And [!Not [!Condition IsExistingRepository], !Condition IsCodeCommitPipelineSource] +Rules: + GitHubPipelineSource: + RuleCondition: !Equals + - !Ref CodePipelineSource + - 'GitHub (via Code Connection)' + Assertions: + - Assert: !Not + - !Equals + - !Ref CodeConnection + - "" + AsertDescription: "The ARN of a Code Connection is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubOwnerName + - "" + AsertDescription: "The Owner (User or Organization) for the GitHub repoitory is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubRepositoryName + - "" + AsertDescription: "The repository name for the GitHub repository is required for a deployment using GitHub as its source" + - Assert: !Not + - !Equals + - !Ref GitHubBranchName + - "" + AsertDescription: "The branch name for the GitHub repository is required for a deployment using GitHub as its source" + Resources: PipelineApprovalTopic: @@ -429,6 +496,7 @@ Resources: Resource: - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${CustomControlTowerCodeBuild} - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${SCPCodeBuild} + - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${RCPCodeBuild} - !Sub arn:${AWS::Partition}:codebuild:${AWS::Region}:${AWS::AccountId}:project/${StackSetCodeBuild} - Effect: "Allow" Action: @@ -450,6 +518,17 @@ Resources: - "sns:Publish" Resource: !Ref PipelineApprovalTopic - !Ref AWS::NoValue + - !If + - IsGitHubPipelineSource + - Effect: "Allow" + Action: + - 'codestar-connections:UseConnection' + - 'codestar-connections:GetConnection' + - 'codestar-connections:ListConnections' + - 'codestar-connections:ListTagsForResource' + Resource: !Ref CodeConnection + - !Ref AWS::NoValue + CustomControlTowerCodePipeline: Type: AWS::CodePipeline::Pipeline @@ -464,24 +543,24 @@ Resources: Actions: - Name: Source ActionTypeId: - !If - - IsCodeCommitPipelineSource - - Category: Source - Owner: AWS - Version: "1" - Provider: CodeCommit - - Category: Source - Owner: AWS - Version: "1" - Provider: S3 + Category: Source + Owner: AWS + Version: 1 + Provider: !If [ IsCodeCommitPipelineSource, CodeCommit, !If [ IsGitHubPipelineSource, CodeStarSourceConnection, S3]] OutputArtifacts: - Name: SourceApp Configuration: !If - - IsCodeCommitPipelineSource - - RepositoryName: !Ref CodeCommitRepositoryName - BranchName: !Ref CodeCommitBranchName - PollForSourceChanges: false + - IsCodeCommitPipelineSource + - RepositoryName: !Ref CodeCommitRepositoryName + BranchName: !Ref CodeCommitBranchName + PollForSourceChanges: false + - !If + - IsGitHubPipelineSource + - ConnectionArn: !Ref CodeConnection + FullRepositoryId: !Sub "${GitHubOwnerName}/${GitHubRepositoryName}" + BranchName: !Ref GitHubBranchName + DetectChanges: true - S3Bucket: !Ref CustomControlTowerPipelineS3Bucket S3ObjectKey: !FindInMap [BucketConfiguration, CustomControlTowerPipelineS3TriggerKey, Name] PollForSourceChanges: false @@ -526,6 +605,18 @@ Resources: Provider: CodeBuild Configuration: ProjectName: !Ref SCPCodeBuild + - Name: ResourceControlPolicy + Actions: + - Name: CodeBuild + InputArtifacts: + - Name: BuiltApp + ActionTypeId: + Category: Build + Owner: AWS + Version: "1" + Provider: CodeBuild + Configuration: + ProjectName: !Ref RCPCodeBuild - Name: CloudformationResource Actions: - Name: CodeBuild @@ -766,6 +857,133 @@ Resources: Type: CODEPIPELINE TimeoutInMinutes: 60 + RCPCodeBuildRole: + Type: "AWS::IAM::Role" + Metadata: + cfn_nag: + rules_to_suppress: + - id: W11 + reason: "Allow * for Organizations APIs to list/describe/move user created child accounts in the AWS Organizations" + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "codebuild.amazonaws.com" + Action: + - "sts:AssumeRole" + Path: "/" + Policies: + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-Logs" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/* + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-S3" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - s3:GetObject + - s3:PutObject + Resource: + - !Sub arn:${AWS::Partition}:s3:::${CustomControlTowerPipelineArtifactS3Bucket}/* + - Effect: "Allow" + Action: + - s3:GetObject + Resource: + - !Sub arn:${AWS::Partition}:s3:::*/* # needed to support validation of remotely sourced templates feature. The host S3 bucket can be created by the customers or partners. + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-StepFunctions" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - states:ListExecutions + - states:StartExecution + - states:StopExecution + - states:DescribeStateMachine + Resource: + - !Ref ResourceControlPolicyMachine + - Effect: Allow + Action: + - states:DescribeStateMachineForExecution + - states:DescribeExecution + Resource: + - !Sub arn:${AWS::Partition}:states:${AWS::Region}:${AWS::AccountId}:execution:${ResourceControlPolicyMachine.Name}:* + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-Organizations" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - organizations:ListRoots + - organizations:ListOrganizationalUnitsForParent + - organizations:ListAccountsForParent + Resource: '*' # The APIs above only support '*' resource. + - PolicyName: "Custom-Control-Tower-RCP-CodeBuild-Policy-SSM" + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParametersByPath + Resource: !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/* + - Effect: Allow + Action: + - ssm:DescribeParameters + Resource: '*' # The APIs above only support '*' resource. + + RCPCodeBuild: + Type: AWS::CodeBuild::Project + DependsOn: CustomControlTowerDeploymentLambda + Properties: + Name: Custom-Control-Tower-RCP-CodeBuild + ServiceRole: !GetAtt RCPCodeBuildRole.Arn + EncryptionKey: !Sub + - alias/${KMSKeyName} + - {KMSKeyName: !FindInMap [KMS, Alias, Name]} + Source: + Type: CODEPIPELINE + BuildSpec: "version: 0.2\nphases:\n install:\n runtime-versions:\n python: 3.11\n ruby: 3.3\n commands:\n - export current=$(pwd)\n - if [ -f manifest.yaml ];then export current=$(pwd);else if [ -f custom-control-tower-configuration/manifest.yaml ]; then export current=$(pwd)/custom-control-tower-configuration; else echo 'manifest.yaml does not exist at the root level of custom-control-tower-configuration.zip or inside custom-control-tower-configuration folder, please check the ZIP file'; exit 1; fi; fi;\n - apt-get -q update 1> /dev/null\n - apt-get -q install zip wget python3-pip libyaml-dev -y 1> /dev/null \n pre_build:\n commands:\n - cd $current\n - echo 'Download CustomControlTower Scripts'\n - aws s3 cp --quiet s3://%SCRIPT_BUCKET_NAME%/%SOLUTION_NAME%/%VERSION%/custom-control-tower-scripts.zip $current\n - unzip -q -o $current/custom-control-tower-scripts.zip -d $current\n - cp codebuild_scripts/* .\n - bash install_stage_dependencies.sh $STAGE_NAME\n build:\n commands:\n - echo 'Starting build $(date) in $(pwd)'\n - echo 'bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES'\n - bash execute_stage_scripts.sh $STAGE_NAME $LOG_LEVEL $WAIT_TIME $SM_ARN $ARTIFACT_BUCKET $KMS_KEY_ALIAS_NAME $BOOL_VALUES $NONE_TYPE_VALUES\n - echo 'Running build scripts completed $(date)'\n post_build:\n commands:\n - echo 'Starting post build $(date) in $(pwd)'\n - echo 'build completed on $(date)'\n\nartifacts:\n files:\n - '**/*'\n" + Environment: + ComputeType: BUILD_GENERAL1_SMALL + Image: "aws/codebuild/standard:7.0" + Type: LINUX_CONTAINER + EnvironmentVariables: + - Name: SM_ARN + Value: !Ref ResourceControlPolicyMachine + - Name: LOG_LEVEL + Value: !FindInMap [LambdaFunction, Logging, Level] + - Name: WAIT_TIME + Value: "15" + - Name: STAGE_NAME + Value: "rcp" + - Name: ARTIFACT_BUCKET + Value: !Ref CustomControlTowerPipelineArtifactS3Bucket + - Name: KMS_KEY_ALIAS_NAME + Value: !FindInMap [KMS, Alias, Name] + - Name: SOLUTION_ID + Value: !FindInMap [ Solution, Metrics, SolutionID ] + - Name: SOLUTION_VERSION + Value: %VERSION% + - Name: AWS_STS_REGIONAL_ENDPOINTS + Value: "regional" + Artifacts: + Name: !Sub ${CustomControlTowerPipelineArtifactS3Bucket}-Built + Type: CODEPIPELINE + TimeoutInMinutes: 60 + StackSetCodeBuildRole: Type: "AWS::IAM::Role" Metadata: @@ -1126,6 +1344,7 @@ Resources: - Fn::Sub: ${CustomControlTowerCodePipelineRole.Arn} - Fn::Sub: ${CustomControlTowerCodeBuildRole.Arn} - Fn::Sub: ${SCPCodeBuildRole.Arn} + - Fn::Sub: ${RCPCodeBuildRole.Arn} - Fn::Sub: ${StackSetCodeBuildRole.Arn} - Fn::Sub: ${CustomControlTowerLELambdaRole.Arn} Service: @@ -1929,6 +2148,557 @@ Resources: } } + ResourceControlPolicyMachine: + Type: 'AWS::StepFunctions::StateMachine' + Properties: + StateMachineName: CustomControlTowerResourceControlPolicyMachine + RoleArn: !GetAtt 'StateMachineRole.Arn' + DefinitionString: + Fn::Sub: |- + { + "Comment": "A state machine that manages the Resource Control Policies.", + "StartAt": "Metrics Pass", + "States": { + "Metrics Pass": { + "Type": "Pass", + "Result": { + "ClassName": "StackSetSMRequests", + "FunctionName": "send_execution_data" + }, + "ResultPath": "$.params", + "Next": "Metrics" + }, + "Metrics": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Create/Delete or Attach/Detach Policy?" + }, + "Create/Delete or Attach/Detach Policy?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.ResourceProperties.AccountId", + "StringEquals": "", + "Next": "Enable Policy Type params" + }, + { + "Variable": "$.ResourceProperties.AccountId", + "StringGreaterThan": "", + "Next": "Attach/Detach Policy params" + } + ] + }, + "Enable Policy Type params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "enable_policy_type" + }, + "ResultPath": "$.params", + "Next": "Enable Policy Type" + }, + "Enable Policy Type": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Wait" + }, + "Wait": { + "Type": "Wait", + "Seconds": 10, + "Next": "Create/Delete Policy params" + }, + "Create/Delete Policy params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies" + }, + "ResultPath": "$.params", + "Next": "Check If Policy Exist?" + }, + "Check If Policy Exist?": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Create or Delete Policy?" + }, + "Create or Delete Policy?": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "no" + } + ], + "Next": "Create Policy Params" + }, + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "yes" + } + ], + "Next": "Update Policy Params" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "yes" + } + ], + "Next": "Detach Policy from All Accounts Params" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.PolicyExist", + "StringEquals": "no" + } + ], + "Next": "Finish" + } + ] + }, + "Create Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "create_policy" + }, + "ResultPath": "$.params", + "Next": "Create Policy" + }, + "Create Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "ConfigureCount2 params" + }, + "Update Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "update_policy" + }, + "ResultPath": "$.params", + "Next": "Update Policy" + }, + "Update Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "ConfigureCount2 params" + }, + "ConfigureCount2 params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "configure_count_2" + }, + "ResultPath": "$.params", + "Next": "ConfigureCount2" + }, + "ConfigureCount2": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Iterator2 params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "iterator2" + }, + "ResultPath": "$.params", + "Next": "Iterator2" + }, + "Iterator2": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "Next": "IsCountReached2" + }, + "IsCountReached2": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Continue", + "BooleanEquals": true, + "Next": "List Policies For OU Params" + } + ], + "Default": "Finish" + }, + "List Policies For OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies_for_ou" + }, + "ResultPath": "$.params", + "Next": "List Policies For OU" + }, + "List Policies For OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Attach or Detach Policy to OU Choice" + }, + "Attach or Detach Policy to OU Choice": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Operation", + "StringEquals": "Attach", + "Next": "Check if Policy is attached to OU?" + }, + { + "Variable": "$.Operation", + "StringEquals": "Detach", + "Next": "Check if Policy is detached from OU?" + } + ], + "Default": "Invalid Operation2" + }, + "Invalid Operation2": { + "Type": "Fail", + "Cause": "Invalid Operation Type, valid choices are [Attach, Detach]", + "Error": "Returning NULL in the response." + }, + "Check if Policy is attached to OU?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Iterator2 params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Attach Policy to OU Params" + } + ], + "Default": "Invalid Operation2" + }, + "Attach Policy to OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "attach_policy" + }, + "ResultPath": "$.params", + "Next": "Attach Policy to OU" + }, + "Attach Policy to OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Check if Policy is detached from OU?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Detach Policy from OU Params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Iterator2 params" + } + ], + "Default": "Invalid Operation2" + }, + "Detach Policy from OU Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy" + }, + "ResultPath": "$.params", + "Next": "Detach Policy from OU" + }, + "Detach Policy from OU": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator2 params" + }, + "Detach Policy from All Accounts Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy_from_all_accounts" + }, + "ResultPath": "$.params", + "Next": "Detach Policy from All Accounts" + }, + "Detach Policy from All Accounts": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Delete Policy Params" + }, + "Delete Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "delete_policy" + }, + "ResultPath": "$.params", + "Next": "Delete Policy" + }, + "Delete Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Finish" + }, + "Attach/Detach Policy params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "configure_count" + }, + "ResultPath": "$.params", + "Next": "ConfigureCount" + }, + "ConfigureCount": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Iterator params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "iterator" + }, + "ResultPath": "$.params", + "Next": "Iterator" + }, + "Iterator": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "Next": "IsCountReached" + }, + "IsCountReached": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Continue", + "BooleanEquals": true, + "Next": "List Policy Params" + } + ], + "Default": "Finish" + }, + "List Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies" + }, + "ResultPath": "$.params", + "Next": "List Policy" + }, + "List Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "List Policies For Account Params" + }, + "List Policies For Account Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "list_policies_for_account" + }, + "ResultPath": "$.params", + "Next": "List Policies For Account" + }, + "List Policies For Account": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Attach or Detach Policy Choice" + }, + "Attach or Detach Policy Choice": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Or": [ + { + "Variable": "$.RequestType", + "StringEquals": "Create" + }, + { + "Variable": "$.RequestType", + "StringEquals": "Update" + } + ] + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Attach" + } + ], + "Next": "Check if Policy is attached?" + }, + { + "And": [ + { + "Variable": "$.RequestType", + "StringEquals": "Delete" + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Attach" + } + ], + "Next": "Check if Policy is detached?" + }, + { + "Variable": "$.ResourceProperties.Operation", + "StringEquals": "Detach", + "Next": "Check if Policy is detached?" + } + ], + "Default": "Invalid Operation" + }, + "Invalid Operation": { + "Type": "Fail", + "Cause": "Invalid Operation Type, valid choices are [Attach, Detach]", + "Error": "Returning NULL in the response." + }, + "Check if Policy is attached?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Iterator params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Attach Policy Params" + } + ], + "Default": "Invalid Operation" + }, + "Attach Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "attach_policy" + }, + "ResultPath": "$.params", + "Next": "Attach Policy" + }, + "Attach Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Check if Policy is detached?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.PolicyAttached", + "StringEquals": "yes", + "Next": "Detach Policy Params" + }, + { + "Variable": "$.PolicyAttached", + "StringEquals": "no", + "Next": "Iterator params" + } + ], + "Default": "Invalid Operation" + }, + "Detach Policy Params": { + "Type": "Pass", + "Result": { + "ClassName": "RCP", + "FunctionName": "detach_policy" + }, + "ResultPath": "$.params", + "Next": "Detach Policy" + }, + "Detach Policy": { + "Type": "Task", + "Resource": "${StateMachineLambda.Arn}", + "TimeoutSeconds": 300, + "HeartbeatSeconds": 60, + "Next": "Iterator params" + }, + "Finish": { + "Type": "Succeed" + } + } + } + StackSetStateMachine: Type: 'AWS::StepFunctions::StateMachine' Properties: diff --git a/deployment/custom_control_tower_configuration/example-configuration/manifest.yaml b/deployment/custom_control_tower_configuration/example-configuration/manifest.yaml index 478fbaa..7443ecb 100644 --- a/deployment/custom_control_tower_configuration/example-configuration/manifest.yaml +++ b/deployment/custom_control_tower_configuration/example-configuration/manifest.yaml @@ -44,5 +44,14 @@ resources: deploy_method: scp #Apply to the following OU(s) deployment_targets: # accounts property is not supported for SCPs + organizational_units: + - + + - name: test-rcp-preventive-guardrails + description: To prevent from deleting or disabling resources in member accounts + resource_file: policies/rcp-preventive-guardrails.json + deploy_method: rcp + #Apply to the following OU(s) + deployment_targets: # accounts property is not supported for RCPs organizational_units: - \ No newline at end of file diff --git a/deployment/custom_control_tower_configuration/example-configuration/policies/rcp-preventive-guardrails.json b/deployment/custom_control_tower_configuration/example-configuration/policies/rcp-preventive-guardrails.json new file mode 100644 index 0000000..2f95bb7 --- /dev/null +++ b/deployment/custom_control_tower_configuration/example-configuration/policies/rcp-preventive-guardrails.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "GuardAccessToBucket", + "Principal": "*", + "Effect": "Deny", + "Action": "s3:*", + "Resource": [ + "arn:aws:s3:::my-bucket", + "arn:aws:s3:::my-bucket/*" + ] + } + ] +} \ No newline at end of file diff --git a/source/codebuild_scripts/execute_stage_scripts.sh b/source/codebuild_scripts/execute_stage_scripts.sh index 0d7d0ab..73bd758 100644 --- a/source/codebuild_scripts/execute_stage_scripts.sh +++ b/source/codebuild_scripts/execute_stage_scripts.sh @@ -4,7 +4,7 @@ if [ -z "$1" ]; then echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." echo "For example: ./execute_stage_scripts.sh " - echo "For example: ./execute_stage_scripts.sh build | scp | stackset" + echo "For example: ./execute_stage_scripts.sh build | scp | rcp | stackset" exit 1 fi @@ -18,6 +18,7 @@ BOOL_VALUES=$7 NONE_TYPE_VALUES=$8 BUILD_STAGE_NAME="build" SCP_STAGE_NAME="scp" +RCP_STAGE_NAME="rcp" STACKSET_STAGE_NAME="stackset" CURRENT=$(pwd) MANIFEST_FILE_PATH=$CURRENT/manifest.yaml @@ -48,6 +49,12 @@ scp_scripts () { python state_machine_trigger.py "$LOG_LEVEL" "$WAIT_TIME" "$MANIFEST_FILE_PATH" "$SM_ARN" "$ARTIFACT_BUCKET" "$SCP_STAGE_NAME" "$KMS_KEY_ALIAS_NAME" } +rcp_scripts () { + echo "Date: $(date) Path: $(pwd)" + echo "python state_machine_trigger.py $LOG_LEVEL $WAIT_TIME $MANIFEST_FILE_PATH $SM_ARN $ARTIFACT_BUCKET $RCP_STAGE_NAME $KMS_KEY_ALIAS_NAME" + python state_machine_trigger.py "$LOG_LEVEL" "$WAIT_TIME" "$MANIFEST_FILE_PATH" "$SM_ARN" "$ARTIFACT_BUCKET" "$RCP_STAGE_NAME" "$KMS_KEY_ALIAS_NAME" +} + stackset_scripts () { echo "Date: $(date) Path: $(pwd)" echo "python state_machine_trigger.py $LOG_LEVEL $WAIT_TIME $MANIFEST_FILE_PATH $SM_ARN $ARTIFACT_BUCKET $STACKSET_STAGE_NAME $KMS_KEY_ALIAS_NAME" @@ -62,6 +69,10 @@ elif [ "$STAGE_NAME_ARGUMENT" == $SCP_STAGE_NAME ]; then echo "Executing SCP Stage Scripts." scp_scripts +elif [ "$STAGE_NAME_ARGUMENT" == $RCP_STAGE_NAME ]; +then + echo "Executing RCP Stage Scripts." + rcp_scripts elif [ "$STAGE_NAME_ARGUMENT" == $STACKSET_STAGE_NAME ]; then echo "Executing StackSet Stage Scripts." diff --git a/source/codebuild_scripts/install_stage_dependencies.sh b/source/codebuild_scripts/install_stage_dependencies.sh index 03c9415..6ed4d0d 100644 --- a/source/codebuild_scripts/install_stage_dependencies.sh +++ b/source/codebuild_scripts/install_stage_dependencies.sh @@ -4,13 +4,14 @@ if [ -z "$1" ]; then echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." echo "For example: ./install_stage_dependencies.sh " - echo "For example: ./install_stage_dependencies.sh build | scp | stackset" + echo "For example: ./install_stage_dependencies.sh build | scp | rcp | stackset" exit 1 fi stage_name_argument=$1 build_stage_name='build' scp_stage_name='scp' +rcp_stage_name='rcp' stackset_stage_name='stackset' install_common_pip_packages () { @@ -46,6 +47,11 @@ scp_dependencies () { install_common_pip_packages } +rcp_dependencies () { + # install pip packages + install_common_pip_packages +} + stackset_dependencies () { # install pip packages install_common_pip_packages @@ -59,6 +65,10 @@ elif [ $stage_name_argument == $scp_stage_name ]; then echo "Installing SCP Stage Dependencies." scp_dependencies +elif [ $stage_name_argument == $rcp_stage_name ]; +then + echo "Installing RCP Stage Dependencies." + rcp_dependencies elif [ $stage_name_argument == $stackset_stage_name ]; then echo "Installing StackSet Stage Dependencies." diff --git a/source/codebuild_scripts/state_machine_trigger.py b/source/codebuild_scripts/state_machine_trigger.py index 141aea2..4127a96 100644 --- a/source/codebuild_scripts/state_machine_trigger.py +++ b/source/codebuild_scripts/state_machine_trigger.py @@ -24,14 +24,15 @@ def main(): """ - This function is triggered by CodePipeline stages (ServiceControlPolicy - and CloudFormationResource). Each stage triggers the following workflow: + This function is triggered by CodePipeline stages (ServiceControlPolicy, + ResourceControlPolicy and CloudFormationResource). + Each stage triggers the following workflow: 1. Parse the manifest file. 2. Generate state machine input. 3. Start state machine execution. 4. Monitor state machine execution. - SCP State Machine currently supports parallel deployments only + SCP & RCP State Machine currently supports parallel deployments only Stack Set State Machine currently support sequential deployments only. :return: None @@ -68,7 +69,12 @@ def main(): sm_input_list = get_scp_inputs() logger.info("SCP sm_input_list:") logger.info(sm_input_list) - + elif stage_name.upper() == "RCP": + # get RCP state machine input list + os.environ["EXECUTION_MODE"] = "parallel" + sm_input_list = get_rcp_inputs() + logger.info("RCP sm_input_list:") + logger.info(sm_input_list) elif stage_name.upper() == "STACKSET": os.environ["EXECUTION_MODE"] = "sequential" sm_input_list = get_stack_set_inputs() @@ -99,6 +105,10 @@ def get_scp_inputs() -> list: return parse.scp_manifest() +def get_rcp_inputs() -> list: + return parse.rcp_manifest() + + def get_stack_set_inputs() -> list: return parse.stack_set_manifest() diff --git a/source/src/cfct/aws/services/rcp.py b/source/src/cfct/aws/services/rcp.py new file mode 100644 index 0000000..ca97531 --- /dev/null +++ b/source/src/cfct/aws/services/rcp.py @@ -0,0 +1,167 @@ +############################################################################## +# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. # +# # +# Licensed under the Apache License, Version 2.0 (the "License"). # +# You may not use this file except in compliance # +# with the License. A copy of the License is located at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# or in the "license" file accompanying this file. This file is # +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # +# KIND, express or implied. See the License for the specific language # +# governing permissions and limitations under the License. # +############################################################################## + +# !/bin/python + +import time +from typing import List + +from botocore.exceptions import ClientError + +from cfct.aws.utils.boto3_session import Boto3Session + + +class ResourceControlPolicy(Boto3Session): + def __init__(self, logger, **kwargs): + self.logger = logger + __service_name = "organizations" + super().__init__(logger, __service_name, **kwargs) + self.org_client = super().get_client() + + def list_policies(self, page_size=20): + try: + paginator = self.org_client.get_paginator("list_policies") + response_iterator = paginator.paginate( + Filter="RESOURCE_CONTROL_POLICY", + PaginationConfig={"PageSize": page_size}, + ) + return response_iterator + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def list_policies_for_target(self, target_id, page_size=20): + try: + paginator = self.org_client.get_paginator("list_policies_for_target") + response_iterator = paginator.paginate( + TargetId=target_id, + Filter="RESOURCE_CONTROL_POLICY", + PaginationConfig={"PageSize": page_size}, + ) + return response_iterator + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def list_targets_for_policy(self, policy_id, page_size=20): + try: + paginator = self.org_client.get_paginator("list_targets_for_policy") + response_iterator = paginator.paginate( + PolicyId=policy_id, PaginationConfig={"PageSize": page_size} + ) + return response_iterator + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def create_policy(self, name, description, content): + try: + response = self.org_client.create_policy( + Content=content, + Description=description, + Name=name, + Type="RESOURCE_CONTROL_POLICY", + ) + return response + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def update_policy(self, policy_id, name, description, content): + try: + response = self.org_client.update_policy( + PolicyId=policy_id, Name=name, Description=description, Content=content + ) + return response + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def delete_policy(self, policy_id): + try: + self.org_client.delete_policy(PolicyId=policy_id) + except ClientError as e: + self.logger.log_unhandled_exception(e) + raise + + def attach_policy(self, policy_id, target_id): + try: + self.org_client.attach_policy(PolicyId=policy_id, TargetId=target_id) + except ClientError as e: + if e.response["Error"]["Code"] == "DuplicatePolicyAttachmentException": + self.logger.exception( + "Caught exception " + "'DuplicatePolicyAttachmentException', " + "taking no action..." + ) + return + else: + self.logger.log_unhandled_exception(e) + raise + + def detach_policy(self, policy_id, target_id): + try: + self.org_client.detach_policy(PolicyId=policy_id, TargetId=target_id) + except ClientError as e: + if e.response["Error"]["Code"] == "PolicyNotAttachedException": + self.logger.exception( + "Caught exception " "'PolicyNotAttachedException'," " taking no action..." + ) + return + else: + self.logger.log_unhandled_exception(e) + raise + + def enable_policy_type(self, root_id, wait_time_sec=5) -> None: + max_retries = 3 + attempts = 0 + + while attempts < max_retries: + # https://awscli.amazonaws.com/v2/documentation/api/latest/reference/organizations/list-roots.html#examples + # Before trying to enable, check what policy types are already enabled + policy_type_metadata: List[dict] = self.org_client.list_roots()["Roots"][0].get( + "PolicyTypes", [] + ) + for policy_metadata in policy_type_metadata: + if policy_metadata["Type"] == "RESOURCE_CONTROL_POLICY": + if policy_metadata["Status"] == "ENABLED": + self.logger.info("RCPs are already enabled, exiting without action") + return + + # RCPs are not enabled - enable them + try: + self.org_client.enable_policy_type( + RootId=root_id, PolicyType="RESOURCE_CONTROL_POLICY" + ) + return + except ClientError as e: + if e.response["Error"]["Code"] == "PolicyTypeAlreadyEnabledException": + self.logger.exception( + "Caught PolicyTypeAlreadyEnabledException, taking no action..." + ) + return + elif e.response["Error"]["Code"] == "ConcurrentModificationException": + # Another instance of the CFCT SFN is enabling RCPs, sleep and retry + attempts += 1 + time.sleep(wait_time_sec) + continue + else: + self.logger.log_unhandled_exception(e) + raise + + # Exceeded retries without finding RCPs enabled + error_msg = f"Unable to enable RCPs in the organization after {max_retries} attempts" + self.logger.log_unhandled_exception(error_msg) + raise Exception(error_msg) diff --git a/source/src/cfct/lambda_handlers/state_machine_router.py b/source/src/cfct/lambda_handlers/state_machine_router.py index 4415027..dd62b57 100644 --- a/source/src/cfct/lambda_handlers/state_machine_router.py +++ b/source/src/cfct/lambda_handlers/state_machine_router.py @@ -18,7 +18,7 @@ import inspect import os -from cfct.state_machine_handler import CloudFormation, ServiceControlPolicy, StackSetSMRequests +from cfct.state_machine_handler import CloudFormation, ServiceControlPolicy, StackSetSMRequests, ResourceControlPolicy from cfct.utils.logger import Logger # initialise logger @@ -154,6 +154,102 @@ def service_control_policy(event, function_name): return response +def resource_control_policy(event, function_name): + rcp = ResourceControlPolicy(event, logger) + logger.info("Router FunctionName: {}".format(function_name)) + if function_name == "list_policies": + response = rcp.list_policies() + elif function_name == "list_policies_for_account": + response = rcp.list_policies_for_account() + elif function_name == "list_policies_for_ou": + response = rcp.list_policies_for_ou() + elif function_name == "create_policy": + response = rcp.create_policy() + elif function_name == "update_policy": + response = rcp.update_policy() + elif function_name == "delete_policy": + response = rcp.delete_policy() + elif function_name == "configure_count": + policy_list = event.get("ResourceProperties").get("PolicyList", []) + logger.info("List of policies: {}".format(policy_list)) + event.update({"Index": 0}) + event.update({"Step": 1}) + event.update({"Count": len(policy_list)}) + return event + elif function_name == "iterator": + index = event.get("Index") + step = event.get("Step") + count = event.get("Count") + policy_list = event.get("ResourceProperties").get("PolicyList", []) + policy_to_apply = policy_list[index] if len(policy_list) > index else None + + if index < count: + _continue = True + else: + _continue = False + + index = index + step + + event.update({"Index": index}) + event.update({"Step": step}) + event.update({"Continue": _continue}) + event.update({"PolicyName": policy_to_apply}) + return event + elif function_name == "attach_policy": + response = rcp.attach_policy() + elif function_name == "detach_policy": + response = rcp.detach_policy() + elif function_name == "detach_policy_from_all_accounts": + response = rcp.detach_policy_from_all_accounts() + elif function_name == "enable_policy_type": + response = rcp.enable_policy_type() + elif function_name == "configure_count_2": + ou_list = event.get("ResourceProperties").get("OUList", []) + logger.info("List of OUs: {}".format(ou_list)) + event.update({"Index": 0}) + event.update({"Step": 1}) + event.update({"Count": len(ou_list)}) + return event + elif function_name == "iterator2": + index = event.get("Index") + step = event.get("Step") + count = event.get("Count") + ou_list = event.get("ResourceProperties").get("OUList", []) + ou_map = ou_list[index] if len(ou_list) > index else None + + if index < count: + _continue = True + else: + _continue = False + + index = index + step + + event.update({"Index": index}) + event.update({"Step": step}) + event.update({"Continue": _continue}) + if ou_map: # ou list example: [['ouname1','ouid1],'Attach'] + logger.info("[state_machine_router.resource_control_policy] ou_map: {}".format(ou_map)) + logger.debug( + "[state_machine_router.resource_control_policy] OUName: {}; OUId: {}; Operation: {}".format( + ou_map[0][0], ou_map[0][1], ou_map[1] + ) + ) + + event.update({"OUName": ou_map[0][0]}) + event.update({"OUId": ou_map[0][1]}) + event.update({"Operation": ou_map[1]}) + + return event + + else: + message = build_messages(1) + logger.info(message) + return {"Message": message} + + logger.info(response) + return response + + def stackset_sm_requests(event, function_name): sr = StackSetSMRequests(event, logger) logger.info("Router FunctionName: {}".format(function_name)) @@ -208,6 +304,8 @@ def lambda_handler(event, context): return stackset_sm_requests(event, function_name) elif class_name == "SCP": return service_control_policy(event, function_name) + elif class_name == "RCP": + return resource_control_policy(event, function_name) else: message = build_messages(2) logger.info(message) diff --git a/source/src/cfct/manifest/manifest_parser.py b/source/src/cfct/manifest/manifest_parser.py index 9c38183..bd76728 100644 --- a/source/src/cfct/manifest/manifest_parser.py +++ b/source/src/cfct/manifest/manifest_parser.py @@ -26,6 +26,7 @@ from cfct.manifest.sm_input_builder import ( InputBuilder, SCPResourceProperties, + RCPResourceProperties, StackSetResourceProperties, ) from cfct.manifest.stage_to_s3 import StageFile @@ -51,6 +52,17 @@ def scp_manifest(): return get_scp_input.parse_scp_manifest_v2() +def rcp_manifest(): + # determine manifest version + manifest = Manifest(os.environ.get("MANIFEST_FILE_PATH")) + if manifest.version == VERSION_1: + get_rcp_input = RCPParser() + return get_rcp_input.parse_rcp_manifest_v1() + elif manifest.version == VERSION_2: + get_rcp_input = RCPParser() + return get_rcp_input.parse_rcp_manifest_v2() + + def stack_set_manifest(): # determine manifest version manifest = Manifest(os.environ.get("MANIFEST_FILE_PATH")) @@ -146,6 +158,60 @@ def parse_scp_manifest_v2(self) -> list: return state_machine_inputs +class RCPParser: + """ + This class parses the Resource Control Policies resources from the manifest + file. It converts the yaml (manifest) into JSON input for the RCP state + machine. + :return List of JSON + + Example: + get_rcp_input = RCPParser() + list_of_inputs = get_rcp_input.parse_rcp_manifest_v1|2() + """ + + def __init__(self): + self.logger = logger + self.manifest = Manifest(os.environ.get("MANIFEST_FILE_PATH")) + + def parse_rcp_manifest_v1(self) -> list: + self.logger.info("Resource Control Policy not supported in V1") + sys.exit(0) + + def parse_rcp_manifest_v2(self) -> list: + state_machine_inputs = [] + self.logger.info( + "[manifest_parser.parse_rcp_manifest_v2] Processing RCPs from {} file".format( + os.environ.get("MANIFEST_FILE_PATH") + ) + ) + build = BuildStateMachineInput(self.manifest.region) + org_data = OrganizationsData() + for resource in self.manifest.resources: + if resource.deploy_method == "rcp": + local_file = StageFile(self.logger, resource.resource_file) + policy_url = local_file.get_staged_file() + attach_ou_list = set(resource.deployment_targets.organizational_units) + + self.logger.debug( + "[manifest_parser.parse_rcp_manifest_v2] attach_ou_list: {} ".format( + attach_ou_list + ) + ) + + # Add ou id to final ou list + final_ou_list = org_data.get_final_ou_list(attach_ou_list) + + state_machine_inputs.append(build.rcp_sm_input(final_ou_list, resource, policy_url)) + + # Exit if there are no organization policies + if len(state_machine_inputs) == 0: + self.logger.info("Organization policies not found" " in the manifest.") + sys.exit(0) + else: + return state_machine_inputs + + class StackSetParser: """ This class parses the Stack Set resources from the manifest file. @@ -295,7 +361,7 @@ def parse_stack_set_manifest_v2(self) -> list: class BuildStateMachineInput: """ - This class build state machine inputs for SCP and Stack Set state machines + This class build state machine inputs for SCP, RCP and Stack Set state machines """ @@ -325,6 +391,25 @@ def scp_sm_input(self, attach_ou_list, policy, policy_url) -> dict: return sm_input + def rcp_sm_input(self, attach_ou_list, policy, policy_url) -> dict: + ou_list = [] + + for ou in attach_ou_list: + ou_list.append((ou, "Attach")) + + resource_properties = RCPResourceProperties( + policy.name, policy.description, policy_url, ou_list + ) + rcp_input = InputBuilder(resource_properties.get_rcp_input_map()) + sm_input = rcp_input.input_map() + + self.logger.debug("&&&&& [manifest_parser.rcp_sm_input] rcp_input &&&&&&") + self.logger.debug(rcp_input) + self.logger.debug("&&&&& [manifest_parser.rcp_sm_input] sm_input &&&&&&") + self.logger.debug(sm_input) + + return sm_input + def stack_set_state_machine_input_v1(self, resource, account_list) -> dict: local_file = StageFile(self.logger, resource.template_file) template_url = local_file.get_staged_file() diff --git a/source/src/cfct/manifest/sm_execution_manager.py b/source/src/cfct/manifest/sm_execution_manager.py index 28fba40..4d5d69a 100644 --- a/source/src/cfct/manifest/sm_execution_manager.py +++ b/source/src/cfct/manifest/sm_execution_manager.py @@ -143,6 +143,8 @@ def run_execution_parallel_mode(self): def get_sm_exec_name(sm_input): if os.environ.get("STAGE_NAME").upper() == "SCP": return sm_input.get("ResourceProperties").get("PolicyDocument").get("Name") + elif os.environ.get("STAGE_NAME").upper() == "RCP": + return sm_input.get("ResourceProperties").get("PolicyDocument").get("Name") elif os.environ.get("STAGE_NAME").upper() == "STACKSET": return sm_input.get("ResourceProperties").get("StackSetName") else: diff --git a/source/src/cfct/manifest/sm_input_builder.py b/source/src/cfct/manifest/sm_input_builder.py index bb3a823..ff1baca 100644 --- a/source/src/cfct/manifest/sm_input_builder.py +++ b/source/src/cfct/manifest/sm_input_builder.py @@ -104,6 +104,60 @@ def _get_policy_document(self): } +class RCPResourceProperties: + """ + This class helps create and return input needed to execute RCP state + machine. This also defines the required keys to execute the state machine. + + Example: + + resource_properties = RCPResourceProperties(name, description, policy_url, + policy_list, account_id, + operation, ou_list, + delimiter, rcp_parameters) + rcp_input = InputBuilder(resource_properties.get_rcp_input_map()) + sm_input = rcp_input.input_map() + + """ + + def __init__( + self, + policy_name, + policy_description, + policy_url, + ou_list, + policy_list=None, + account_id="", + operation="", + ou_name_delimiter=":", + ): + self._policy_name = policy_name + self._policy_description = policy_description + self._policy_url = policy_url + self._policy_list = [] if policy_list is None else policy_list + self._account_id = account_id + self._operation = operation + self._ou_list = ou_list + self._ou_name_delimiter = ou_name_delimiter + + def get_rcp_input_map(self): + return { + "PolicyDocument": self._get_policy_document(), + "AccountId": self._account_id, + "PolicyList": self._policy_list, + "Operation": self._operation, + "OUList": self._ou_list, + "OUNameDelimiter": self._ou_name_delimiter, + } + + def _get_policy_document(self): + return { + "Name": self._policy_name, + "Description": self._policy_description, + "PolicyURL": self._policy_url, + } + + class StackSetResourceProperties: """ This class helps create and return input needed to execute Stack Set diff --git a/source/src/cfct/state_machine_handler.py b/source/src/cfct/state_machine_handler.py index 961a747..0d878a7 100644 --- a/source/src/cfct/state_machine_handler.py +++ b/source/src/cfct/state_machine_handler.py @@ -26,6 +26,7 @@ from cfct.aws.services.organizations import Organizations as Org from cfct.aws.services.s3 import S3 from cfct.aws.services.scp import ServiceControlPolicy as SCP +from cfct.aws.services.rcp import ResourceControlPolicy as RCP from cfct.aws.services.ssm import SSM from cfct.aws.services.sts import AssumeRole from cfct.aws.utils.url_conversion import parse_bucket_key_names @@ -898,6 +899,223 @@ def enable_policy_type(self): return self.event +class ResourceControlPolicy(object): + """ + This class handles requests from Resource Control Policy State Machine. + """ + + def __init__(self, event, logger): + self.event = event + self.params = event.get("ResourceProperties") + self.logger = logger + self.logger.info(self.__class__.__name__ + " Class Event") + self.logger.info(event) + + def _load_policy(self, http_policy_path): + bucket_name, key_name, region = parse_bucket_key_names(http_policy_path) + policy_file = tempfile.mkstemp()[1] + s3_endpoint_url = "https://s3.%s.amazonaws.com" % region + s3 = S3(self.logger, region=region, endpoint_url=s3_endpoint_url) + s3.download_file(bucket_name, key_name, policy_file) + + self.logger.info("Parsing the policy file: {}".format(policy_file)) + + with open(policy_file, "r") as content_file: + policy_file_content = content_file.read() + + # Check if valid json + json.loads(policy_file_content) + # Return the Escaped JSON text + + return policy_file_content.replace('"', '"').replace("\n", "\r\n").replace(" ", "") + + def list_policies(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + # Check if PolicyName attribute exists in event, + # if so, it is called for attach or detach policy + if "PolicyName" in self.event: + policy_name = self.event.get("PolicyName") + else: + policy_name = self.params.get("PolicyDocument").get("Name") + + # Check if RCP already exist + rcp = RCP(self.logger) + pages = rcp.list_policies() + + for page in pages: + policies_list = page.get("Policies") + + # iterate through the policies list + for policy in policies_list: + if policy.get("Name") == policy_name: + self.logger.info("Policy Found") + self.event.update({"PolicyId": policy.get("Id")}) + self.event.update({"PolicyArn": policy.get("Arn")}) + self.event.update({"PolicyExist": "yes"}) + return self.event + else: + continue + + self.event.update({"PolicyExist": "no"}) + return self.event + + def create_policy(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + policy_doc = self.params.get("PolicyDocument") + + rcp = RCP(self.logger) + self.logger.info("Creating Resource Control Policy") + policy_content = self._load_policy(policy_doc.get("PolicyURL")) + + response = rcp.create_policy( + policy_doc.get("Name"), policy_doc.get("Description"), policy_content + ) + self.logger.info("Create RCP Response") + self.logger.info(response) + policy_id = response.get("Policy").get("PolicySummary").get("Id") + self.event.update({"PolicyId": policy_id}) + return self.event + + def update_policy(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + policy_doc = self.params.get("PolicyDocument") + policy_id = self.event.get("PolicyId") + policy_content = self._load_policy(policy_doc.get("PolicyURL")) + + rcp = RCP(self.logger) + self.logger.info("Updating Resource Control Policy") + response = rcp.update_policy( + policy_id, + policy_doc.get("Name"), + policy_doc.get("Description"), + policy_content, + ) + self.logger.info("Update RCP Response") + self.logger.info(response) + policy_id = response.get("Policy").get("PolicySummary").get("Id") + self.event.update({"PolicyId": policy_id}) + return self.event + + def delete_policy(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + policy_id = self.event.get("PolicyId") + + rcp = RCP(self.logger) + self.logger.info("Deleting Resource Control Policy") + rcp.delete_policy(policy_id) + self.logger.info("Delete RCP") + status = "Policy: {} deleted successfully".format(policy_id) + self.event.update({"Status": status}) + return self.event + + def attach_policy(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + if self.params.get("AccountId") == "": + target_id = self.event.get("OUId") + else: + target_id = self.params.get("AccountId") + policy_id = self.event.get("PolicyId") + rcp = RCP(self.logger) + rcp.attach_policy(policy_id, target_id) + self.logger.info("Attach Policy") + status = "Policy: {} attached successfully to Target: {}".format(policy_id, target_id) + self.event.update({"Status": status}) + return self.event + + def detach_policy(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + if self.params.get("AccountId") == "": + target_id = self.event.get("OUId") + else: + target_id = self.params.get("AccountId") + policy_id = self.event.get("PolicyId") + rcp = RCP(self.logger) + rcp.detach_policy(policy_id, target_id) + self.logger.info("Detach Policy Response") + status = "Policy: {} detached successfully from Target: {}".format(policy_id, target_id) + self.event.update({"Status": status}) + return self.event + + def list_policies_for_ou(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + ou_name = self.event.get("OUName") + policy_name = self.params.get("PolicyDocument").get("Name") + ou_id = self.event.get("OUId") + if ou_id is None or len(ou_id) == 0: + raise ValueError("OU id is not found for {}".format(ou_name)) + self.event.update({"OUId": ou_id}) + self.list_policies_for_target(ou_id, policy_name) + + return self.event + + def list_policies_for_account(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + self.list_policies_for_target(self.params.get("AccountId"), self.event.get("PolicyName")) + return self.event + + def list_policies_for_target(self, target_id, policy_name): + # Check if RCP already exist + rcp = RCP(self.logger) + pages = rcp.list_policies_for_target(target_id) + + for page in pages: + policies_list = page.get("Policies") + + # iterate through the policies list + for policy in policies_list: + if policy.get("Name") == policy_name: + self.logger.info("Policy Found") + self.event.update({"PolicyId": policy.get("Id")}) + self.event.update({"PolicyArn": policy.get("Arn")}) + self.event.update({"PolicyAttached": "yes"}) + return self.event + else: + continue + + self.event.update({"PolicyAttached": "no"}) + + def detach_policy_from_all_accounts(self): + self.logger.info("Executing: " + self.__class__.__name__ + "/" + inspect.stack()[0][3]) + self.logger.info(self.params) + policy_id = self.event.get("PolicyId") + rcp = RCP(self.logger) + + pages = rcp.list_targets_for_policy(policy_id) + accounts = [] + + for page in pages: + target_list = page.get("Targets") + + # iterate through the policies list + for target in target_list: + account_id = target.get("TargetId") + rcp.detach_policy(policy_id, account_id) + accounts.append(account_id) + + status = "Policy: {} detached successfully from Accounts: {}".format(policy_id, accounts) + self.event.update({"Status": status}) + return self.event + + def enable_policy_type(self): + org = Org(self.logger) + response = org.list_roots() + self.logger.info("List roots Response") + self.logger.info(response) + root_id = response["Roots"][0].get("Id") + + rcp = RCP(self.logger) + rcp.enable_policy_type(root_id) + return self.event + + class StackSetSMRequests(object): """ This class handles requests from Cloudformation (StackSet) State Machine. diff --git a/source/src/cfct/validation/manifest-v2.schema.yaml b/source/src/cfct/validation/manifest-v2.schema.yaml index 0acf2ed..7279425 100644 --- a/source/src/cfct/validation/manifest-v2.schema.yaml +++ b/source/src/cfct/validation/manifest-v2.schema.yaml @@ -56,7 +56,7 @@ mapping: "deploy_method": type: str required: True - enum: ['scp', 'stack_set'] + enum: ['scp', 'stack_set', 'rcp'] "regions": type: seq sequence: diff --git a/source/src/setup.py b/source/src/setup.py index 605ff34..9aa1026 100644 --- a/source/src/setup.py +++ b/source/src/setup.py @@ -28,7 +28,7 @@ python_requires=">=3.11", install_requires=[ "yorm==1.6.2", - "pyyaml>=5.4.1", + "pyyaml==5.4.1", "Jinja2==3.1.4", "MarkupSafe==2.0.1", # https://github.com/pallets/jinja/issues/1585 "requests==2.32.2",