├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── Makefile.AWSOrganized ├── Makefile.CI ├── Makefile.CodeQuality ├── Makefile.Project ├── NOTICE ├── README.md ├── aws_organized ├── __init__.py ├── aws_organized.py ├── cli.py ├── extensions │ ├── __init__.py │ ├── delegated_administrators │ │ ├── __init__.py │ │ ├── delegated_administrators.py │ │ └── migrations.py │ └── service_control_policies │ │ ├── __init__.py │ │ ├── migrations.py │ │ └── service_control_policies.py ├── helpers.py └── migrations.py ├── poetry.lock ├── pyproject.toml └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | environment 3 | __pycache__ 4 | state.yaml 5 | .venv 6 | .DS_Store 7 | ignored 8 | dist 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | .PHONY: help install pre-build build bump-patch bump-minor bump-major version bootstrap bootstrap-branch expand deploy clean deploy-spoke black pycodestyle 5 | .DEFAULT_GOAL := help 6 | 7 | 8 | include Makefile.CodeQuality 9 | include Makefile.AWSOrganized 10 | include Makefile.CI 11 | include Makefile.Project 12 | 13 | help: help-prefix help-targets 14 | 15 | help-prefix: 16 | @echo Usage: 17 | @echo ' make ' 18 | @echo ' make = ' 19 | @echo '' 20 | @echo Available targets 21 | 22 | HELP_TARGET_MAX_CHAR_NUM = 25 23 | 24 | help-targets: 25 | @awk '/^[a-zA-Z\-\_0-9]+:/ \ 26 | { \ 27 | helpMessage = match(lastLine, /^## (.*)/); \ 28 | if (helpMessage) { \ 29 | helpCommand = substr($$1, 0, index($$1, ":")-1); \ 30 | helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ 31 | helpGroup = match(helpMessage, /^@([^ ]*)/); \ 32 | if (helpGroup) { \ 33 | helpGroup = substr(helpMessage, RSTART + 1, index(helpMessage, " ")-2); \ 34 | helpMessage = substr(helpMessage, index(helpMessage, " ")+1); \ 35 | } \ 36 | printf "[ %s| %-$(HELP_TARGET_MAX_CHAR_NUM)s %s\n", \ 37 | helpGroup, helpCommand, helpMessage; \ 38 | } \ 39 | } \ 40 | { lastLine = $$0 }' \ 41 | $(MAKEFILE_LIST) \ 42 | | sort -t'|' -sk1,1 \ 43 | | awk -F '|' ' \ 44 | { \ 45 | cat = $$1; \ 46 | if (cat != lastCat || lastCat == "") { \ 47 | if ( cat == "0" ) { \ 48 | print "Targets:" \ 49 | } else { \ 50 | gsub("_", " ", cat); \ 51 | printf "%s ] \n", cat; \ 52 | } \ 53 | } \ 54 | print " " $$2 \ 55 | } \ 56 | { lastCat = $$1 }' 57 | -------------------------------------------------------------------------------- /Makefile.AWSOrganized: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | .PHONY: import-organization make-migrations migrate clean 5 | 6 | CICD_ACCOUNT_ID="156551640785" 7 | ORG_MANAGEMENT_ACCOUNT_ID=156551640785 8 | 9 | IMPORT_ROLE_ARN="arn:aws:iam::$(ORG_MANAGEMENT_ACCOUNT_ID):role/AWSOrganized/ImportOrganizationRole" 10 | MAKE_MIGRATIONS_ROLE_ARN = "arn:aws:iam::$(ORG_MANAGEMENT_ACCOUNT_ID):role/AWSOrganized/MakeMigrationsRole" 11 | MIGRATE_ROLE_ARN = "arn:aws:iam::$(ORG_MANAGEMENT_ACCOUNT_ID):role/AWSOrganized/MigrateRole" 12 | 13 | import-organization: 14 | poetry run aws-organized import-organization $(IMPORT_ROLE_ARN) 15 | 16 | generate-import-organization-role-template: 17 | poetry run aws-organized generate-import-organization-role-template $$(aws sts get-caller-identity --query Account --output text) 18 | 19 | provision-import-organization-role-stack: 20 | poetry run aws-organized provision-import-organization-role-stack $(CICD_ACCOUNT_ID) 21 | 22 | make-migrations: 23 | poetry run aws-organized make-migrations $(MAKE_MIGRATIONS_ROLE_ARN) 24 | 25 | generate-make-migrations-role-template: 26 | poetry run aws-organized generate-make-migrations-role-template $$(aws sts get-caller-identity --query Account --output text) 27 | 28 | provision-make-migrations-role-stack: 29 | poetry run aws-organized provision-make-migrations-role-stack $(CICD_ACCOUNT_ID) 30 | 31 | migrate: 32 | poetry run aws-organized migrate $(MIGRATE_ROLE_ARN) 33 | 34 | generate-migrate-role-template: 35 | poetry run aws-organized generate-migrate-role-template $$(aws sts get-caller-identity --query Account --output text) 36 | 37 | provision-migrate-role-stack: 38 | poetry run aws-organized provision-migrate-role-stack $(CICD_ACCOUNT_ID) 39 | 40 | generate-codepipeline-template: 41 | poetry run aws-organized generate-codepipeline-template $(MIGRATE_ROLE_ARN) 42 | 43 | provision-codepipeline-stack: 44 | poetry run aws-organized provision-codepipeline-stack $(MIGRATE_ROLE_ARN) 45 | -------------------------------------------------------------------------------- /Makefile.CI: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | .PHONY: install pre-build build 5 | 6 | ## @CI_actions Installs the checked out version of the code to your poetry managed venv 7 | install: 8 | poetry install 9 | 10 | ## @CI_actions Runs code quality checks 11 | pre-build: black unit-tests 12 | rm setup.py || echo "There was no setup.py" 13 | poetry show --no-dev | awk '{print "poetry add "$$1"=="$$2}' | sort | sh 14 | 15 | ## @CI_actions Builds the project into an sdist 16 | build: 17 | poetry build -f sdist 18 | -------------------------------------------------------------------------------- /Makefile.CodeQuality: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | .PHONY: black pycodestyle 5 | 6 | ## @Code_quality Runs black on the checked out code 7 | black: 8 | poetry run black aws_organized 9 | 10 | ## @Code_quality Runs pycodestyle on the the checked out code 11 | pycodestyle: 12 | poetry run pycodestyle --statistics -qq aws_organized 13 | -------------------------------------------------------------------------------- /Makefile.Project: -------------------------------------------------------------------------------- 1 | .PHONY: bump-patch bump-minor bump-major clean prepare-for-testing 2 | 3 | ## @Project_setup Increment patch number 4 | bump-patch: 5 | poetry version patch 6 | 7 | ## @Project_setup Increment minor number 8 | bump-minor: 9 | poetry version minor 10 | 11 | ## @Project_setup Increment major number 12 | bump-major: 13 | poetry version major 14 | 15 | ## @Project_setup Cleans up after a build 16 | clean: 17 | rm -rf environment 18 | rm -rf dist 19 | 20 | ## @Project_setup Generates a setup.py so you can test bootstrapped branches in AWS Codecommit 21 | prepare-for-testing: build 22 | tar -zxvf dist/$$(poetry version | sed 's/ /-/g').tar.gz -C dist $$(poetry version | sed 's/ /-/g')/setup.py 23 | mv dist/aws-organized-*/setup.py setup.py 24 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Organized 2 | 3 | ## What does this do? 4 | 5 | This library does two things for you: 6 | 7 | 1. It allows you to visualise and make changes to your AWS Organization using folders and files 8 | 1. Instead of making changes directly you build up a migration which can then be reviewed before being applied. 9 | 10 | ### How does it do this 11 | 12 | Using a read only role with access to your AWS Organization you run an import-organization command. This generates a 13 | directory that represents your AWS Organization. It contains directories for OUs and accounts. It contains files 14 | describing the OUs, accounts and SCP policies. 15 | 16 | You then make changes to the files and folders - for example, you move account folders to other OU folders to move the 17 | account. 18 | 19 | Once you are happy with your changes you run a make-migrations command. This generates some migrations files that 20 | describe what changes you are making. These changes should be reviewed and then added to git. You can then use your 21 | fave branching strategy to approve the change in your team. Once the changes are in your mainline they trigger a 22 | pipeline that will run your migrations using a read/write role. 23 | 24 | ## How can I use this? 25 | 26 | ### Installing 27 | 28 | This tool has been built in Python 3.7. We recommend using [pipx](https://github.com/pipxproject/pipx) to install this 29 | tool: 30 | 31 | ```shell script 32 | pipx install aws-organized 33 | ``` 34 | 35 | #### Setting up the IAM Roles 36 | 37 | This tool ships with definitions for each IAM role with minimal permissions. 38 | 39 | You can see and execute the commands as follows: 40 | 41 | ##### Import Organization 42 | The import organization command requires an IAM role in the Organizations management account. Before you provision the 43 | role you need to decide where this tool will be installed. We recommend installing the tool into a dedicated prod 44 | shared services foundation account. Once you have that account which we will call the organized account you are ready 45 | to view or provision the template or stack. 46 | 47 | *You will need to provision this stack into your AWS Organizations management account* 48 | 49 | To preview the template you can run: 50 | 51 | ```shell script 52 | aws-organized generate-import-organization-role-template 53 | ``` 54 | 55 | To provision the stack you can run: 56 | 57 | ```shell script 58 | aws-organized provision-import-organization-role-stack 59 | ``` 60 | 61 | ##### Make Migrations 62 | The make-migrations command requires an IAM role in the Organizations management account. 63 | 64 | *You will need to provision this stack into your AWS Organizations management account* 65 | 66 | To preview the template you can run: 67 | 68 | ```shell script 69 | aws-organized generate-make-migrations-role-template 70 | ``` 71 | 72 | To provision the stack you can run: 73 | 74 | ```shell script 75 | aws-organized provision-make-migrations-role-stack 76 | ``` 77 | 78 | 79 | ##### Migrate 80 | The migrate command requires an IAM role in the Organizations management account. 81 | 82 | *You will need to provision this stack into your AWS Organizations management account* 83 | 84 | To preview the template you can run: 85 | 86 | ```shell script 87 | aws-organized generate-migrate-role-template 88 | ``` 89 | 90 | To provision the stack you can run: 91 | 92 | ```shell script 93 | aws-organized provision-migrate-role-stack 94 | ``` 95 | 96 | 97 | #### Setting up the pipelines 98 | 99 | We recommend running the migrate command in a pipeline so that it is run in a controlled environment where history is 100 | recorded and so audit is possible. 101 | 102 | You can run this in AWS CodePipeline using our template. 103 | 104 | When running you have the option of which SCM you would like to use 105 | 106 | ##### AWS CodeCommit 107 | 108 | preview the template: 109 | 110 | ```shell script 111 | aws-organized generate-codepipeline-template 112 | ``` 113 | 114 | provision the template: 115 | 116 | ```shell script 117 | aws-organized provision-codepipeline-stack 118 | ``` 119 | Please note, you can specify --scm-full-repository-id to provide the name of the repo and you can use scm-branch-name to provide a branch. If you omit either a default value will be used. 120 | 121 | Finally, you can specify --scm-skip-creation-of-repo and the template will not include the AWS CodeCommit repo - you can bring your own. 122 | 123 | ##### AWS S3 124 | 125 | preview the template: 126 | 127 | ```shell script 128 | aws-organized generate-codepipeline-template --scm-provider s3 --scm-bucket-name foo --scm-object-key environment.zip 129 | ``` 130 | 131 | provision the template: 132 | 133 | ```shell script 134 | aws-organized provision-codepipeline-stack --scm-provider s3 --scm-bucket-name foo --scm-object-key environment.zip 135 | ``` 136 | 137 | Please note if you omit --scm-bucket-name we will auto generate a bucket name for you. If you omit --scm-object-key we will generate a value for you. 138 | 139 | Finally, you can specify --scm-skip-creation-of-repo and the template will not include the AWS S3 bucket - you can bring your own. 140 | 141 | ##### Github / Github Enterprise / Bitbucket cloud (via CodeStarSourceConnections) 142 | 143 | preview the template: 144 | 145 | ```shell script 146 | aws-organized generate-codepipeline-template --scm-provider CodeStarSourceConnection --scm-connection-arn --scm-full-repository-id --scm-branch-name 147 | ``` 148 | 149 | provision the template: 150 | 151 | ```shell script 152 | aws-organized provision-codepipeline-stack --scm-provider CodeStarSourceConnection --scm-connection-arn --scm-full-repository-id --scm-branch-name 153 | ``` 154 | If you do not provide values for --scm-full-repository-id or --scm-branch-name default values will be provided for you. 155 | 156 | ### Making changes to your Org 157 | Before you can make changes you need to run: 158 | 159 | ```shell script 160 | aws-organized import-organization 161 | ``` 162 | 163 | where `import-organization-role` is the role created by the `provision-import-organization-role-stack` command 164 | 165 | Once you run the import-organization command you have a directory created containing the accounts, OUs and SCPs defined: 166 | 167 | ```shell script 168 | environment 169 | └── r-japk 170 | ├── _accounts 171 | │   └── eamonnf+SCT-demo-hub 172 | │   ├── _meta.yaml 173 | │   └── _service_control_policies.yaml 174 | ├── _meta.yaml 175 | ├── _migrations 176 | │   ├── 1613407148.432513_POLICY_CONTENT_UPDATE.yaml 177 | │   └── 1613407148.435472_POLICY_CREATE.yaml 178 | ├── _organizational_units 179 | │   ├── foo 180 | │   │   ├── _meta.yaml 181 | │   │   ├── _organizational_units 182 | │   │   │   └── bar 183 | │   │   │   ├── _meta.yaml 184 | │   │   │   ├── _organizational_units 185 | │   │   │   └── _service_control_policies.yaml 186 | │   │   └── _service_control_policies.yaml 187 | │   └── sharedservices 188 | │   ├── _accounts 189 | │   │   ├── eamonnf+SCT-demo-spoke-1 190 | │   │   │   ├── _meta.yaml 191 | │   │   │   └── _service_control_policies.yaml 192 | │   │   ├── eamonnf+SCT-demo-spoke-2 193 | │   │   │   ├── _meta.yaml 194 | │   │   │   └── _service_control_policies.yaml 195 | │   │   ├── eamonnf+SCT-demo-spoke-4 196 | │   │   │   ├── _meta.yaml 197 | │   │   │   └── _service_control_policies.yaml 198 | │   │   └── eamonnf+SCT-demo-spoke-5 199 | │   │   ├── _meta.yaml 200 | │   │   └── _service_control_policies.yaml 201 | │   ├── _meta.yaml 202 | │   ├── _organizational_units 203 | │   │   └── infra 204 | │   │   ├── _accounts 205 | │   │   │   └── eamonnf+SCT-demo-spoke-3 206 | │   │   │   ├── _meta.yaml 207 | │   │   │   └── _service_control_policies.yaml 208 | │   │   ├── _meta.yaml 209 | │   │   ├── _organizational_units 210 | │   │   └── _service_control_policies.yaml 211 | │   └── _service_control_policies.yaml 212 | ├── _policies 213 | │   └── service_control_policies 214 | │   ├── FullAWSAccess 215 | │   │   ├── _meta.yaml 216 | │   │   └── policy.json 217 | │   ├── OnlyEc2 218 | │   │   └── policy.json 219 | │   └── OnlyS3 220 | │   ├── _meta.yaml 221 | │   └── policy.json 222 | └── _service_control_policies.yaml 223 | 224 | 225 | 226 | ``` 227 | 228 | You can currently perform the following operations: 229 | 230 | #### Core features 231 | The following capabilities are provided: 232 | 233 | ##### Create an OU 234 | To create an OU you need to create a directory within a new or existing _organizational_units directory. When creating 235 | a directory you should not add the _meta.yaml file yourself. You should name the directory with the name of the OU 236 | you want to use. 237 | 238 | ##### Rename an OU 239 | To rename an OU you need to rename the directory for the OU. You should not edit the attributes in the _meta.yaml file. 240 | 241 | ##### Move an account 242 | To move an account from one OU to another you have to move the directory for the account. You should move the contents 243 | of the directory with it - including the _meta.yaml and _service_control_policies.yaml files. 244 | 245 | #### Service Control Policy features 246 | The following capabilities are provided: 247 | 248 | ##### Create a policy 249 | To create a policy you need to add a directory in the _policies/service_control_policies directory. The name of the 250 | directory becomes the initial name for the policy. Within the directory you need to add a file policy.json which 251 | contains the actual SCP policy you want to attach. When you create a policy do not add a _meta.yaml file for it, the 252 | tool will add it for you. When you create a policy you cannot set the description, that needs to be another change. 253 | 254 | ##### Update a policy 255 | To update a policy you either modify the _meta.yaml file or the policy.json file. If you want to change the 256 | description change the attribute in your _meta.yaml file. If you want to change the policy content you will need to 257 | edit the policy.json. At the moment you cannot change the policy name. 258 | 259 | ##### Attach a policy 260 | To attach a policy to an OU or an account you should add it to the Attached section of the 261 | _service_control_policies.yaml file. Once you have added it, it should look like this: 262 | 263 | ```yaml 264 | Attached: 265 | - Arn: arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess 266 | AwsManaged: true 267 | Description: Allows access to every operation 268 | Id: p-FullAWSAccess 269 | Name: FullAWSAccess 270 | Type: SERVICE_CONTROL_POLICY 271 | - Name: OnlyS3 272 | Inherited: 273 | - Arn: arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess 274 | AwsManaged: true 275 | Description: Allows access to every operation 276 | Id: p-FullAWSAccess 277 | Name: FullAWSAccess 278 | Source: sharedservices 279 | Type: SERVICE_CONTROL_POLICY 280 | ``` 281 | In the above example we appended the name only: 282 | 283 | ```yaml 284 | Name: OnlyS3 285 | ``` 286 | 287 | AWS-Organized will look up the rest of the details for you. 288 | 289 | ### Generating migrations 290 | Once you have made your changes you can then run `aws-organized make-migrations ` where 291 | make-migrations-role-arn is the Arn of the role created in the steps above. 292 | 293 | This creates a _migrations directory in your environment/organization direction. Within the _migrations directory 294 | there should be a file describing the change you want to make. 295 | 296 | ### Applying migrations 297 | Once you have made your migrations you will want to review them - they are human (ish) readable YAML documents that 298 | describe the change you are applying. Once you are happy with them you will want to run them. 299 | 300 | #### Running migrations in a pipeline (recommended) 301 | Once you have your migrations you add them to the git repository created in the create pipeline step above. The default 302 | name for the git repo is `AWS-Organized-environment` 303 | 304 | #### Running migrations locally (not recommended) 305 | Once you have your migrations you can then run `aws-organized migrate ` where 306 | migrate-role-arn is the Arn of the role created in the steps above. 307 | 308 | 309 | ## Security 310 | 311 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 312 | 313 | ## License 314 | 315 | This project is licensed under the Apache-2.0 License. 316 | 317 | # 318 | -------------------------------------------------------------------------------- /aws_organized/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /aws_organized/aws_organized.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | import os 5 | 6 | from betterboto import client as betterboto_client 7 | 8 | from boto3 import client 9 | 10 | import yaml 11 | import click 12 | import logging 13 | import sys 14 | from aws_organized import migrations 15 | from aws_organized.extensions.service_control_policies import service_control_policies 16 | from aws_organized.extensions.delegated_administrators import delegated_administrators 17 | from datetime import datetime 18 | from progress import bar 19 | 20 | 21 | logging.disable(sys.maxsize) 22 | 23 | STATE_FILE = "state.yaml" 24 | SERVICE_CONTROL_POLICY = "SERVICE_CONTROL_POLICY" 25 | ORGANIZATIONAL_UNIT = "ORGANIZATIONAL_UNIT" 26 | META_FILE_NAME = "_meta.yaml" 27 | SEP = os.path.sep 28 | 29 | EXTENSION = "aws_organized" 30 | 31 | 32 | def list_policies_for_target(organizations: client, id: str, filter) -> dict: 33 | return organizations.list_policies_for_target( 34 | TargetId=id, 35 | Filter=filter, 36 | ).get("Policies", []) 37 | 38 | 39 | def describe_organizational_unit(organizations: client, id: str) -> dict: 40 | return organizations.describe_organizational_unit(OrganizationalUnitId=id).get( 41 | "OrganizationalUnit" 42 | ) 43 | 44 | 45 | def list_children(organizations: client, id: str, child_type: str) -> dict: 46 | return organizations.list_children_single_page( 47 | ParentId=id, 48 | ChildType=child_type, 49 | ).get("Children", []) 50 | 51 | 52 | def get_service_control_policies_for_target(organizations: client, id: str) -> dict: 53 | service_control_policies_for_target = dict() 54 | for policy in list_policies_for_target(organizations, id, SERVICE_CONTROL_POLICY): 55 | service_control_policies_for_target[policy.get("Id")] = policy 56 | return service_control_policies_for_target 57 | 58 | 59 | def get_children_details_filter_by_organizational_unit( 60 | organizations: client, id: str, by_name: dict, parent_path: str, by_id: dict 61 | ) -> dict: 62 | result = dict() 63 | organizational_units = list_children(organizations, id, ORGANIZATIONAL_UNIT) 64 | for organizational_unit in organizational_units: 65 | organizational_unit_id = organizational_unit.get("Id") 66 | 67 | details = describe_organizational_unit(organizations, organizational_unit_id) 68 | path = parent_path + "/" + details.get("Name") 69 | by_name[path] = details.get("Id") 70 | by_id[details.get("Id")] = dict(path=path, details=details) 71 | 72 | result[organizational_unit_id] = dict( 73 | details=details, 74 | parent_id=id, 75 | policies=dict( 76 | service_control_policies=get_service_control_policies_for_target( 77 | organizations, organizational_unit_id 78 | ), 79 | ), 80 | organizational_units=get_children_details_filter_by_organizational_unit( 81 | organizations, organizational_unit_id, by_name, path, by_id 82 | ), 83 | ) 84 | return result 85 | 86 | 87 | def update_state(role_arn) -> None: 88 | with betterboto_client.CrossAccountClientContextManager( 89 | "organizations", 90 | role_arn, 91 | f"organizations", 92 | ) as organizations: 93 | all_accounts = dict() 94 | result = dict(accounts=all_accounts) 95 | list_roots_response = organizations.list_roots_single_page() 96 | tree = dict() 97 | by_name = dict() 98 | by_id = dict() 99 | organizational_units = dict(tree=tree, by_name=by_name, by_id=by_id) 100 | result["organizational_units"] = organizational_units 101 | progress = bar.IncrementalBar( 102 | "Adding roots", max=len(list_roots_response.get("Roots", [])) 103 | ) 104 | for root in list_roots_response.get("Roots", []): 105 | progress.next() 106 | root_id = str(root.get("Id")) 107 | details = dict( 108 | Type="Root", 109 | Id=root_id, 110 | Name="Root", 111 | ) 112 | by_name["/"] = root_id 113 | by_id[root_id] = dict(path="/", details=details) 114 | tree[root_id] = dict( 115 | details=details, 116 | organizational_units=get_children_details_filter_by_organizational_unit( 117 | organizations, root_id, by_name, "", by_id 118 | ), 119 | policies=dict( 120 | service_control_policies=organizations.list_policies_for_target( 121 | TargetId=root_id, 122 | Filter=SERVICE_CONTROL_POLICY, 123 | ).get("Policies", []), 124 | ), 125 | ) 126 | progress.finish() 127 | 128 | with open(STATE_FILE, "w") as f: 129 | f.write(yaml.safe_dump(result)) 130 | 131 | accounts = organizations.list_accounts_single_page().get("Accounts", []) 132 | progress = bar.IncrementalBar("Adding accounts", max=len(accounts)) 133 | counter = 1 134 | for account in accounts: 135 | progress.next() 136 | account_id = account.get("Id") 137 | all_accounts[account_id] = dict( 138 | details=account, 139 | parents=organizations.list_parents_single_page(ChildId=account_id).get( 140 | "Parents" 141 | ), 142 | policies=dict( 143 | service_control_policies=organizations.list_policies_for_target( 144 | TargetId=account_id, 145 | Filter=SERVICE_CONTROL_POLICY, 146 | ).get("Policies", []), 147 | ), 148 | ) 149 | counter += 1 150 | progress.finish() 151 | with open(STATE_FILE, "w") as f: 152 | f.write(yaml.safe_dump(result)) 153 | return result 154 | 155 | 156 | def write_details(details: dict, output_dir: str) -> None: 157 | details_file = SEP.join([output_dir, META_FILE_NAME]) 158 | with open(details_file, "w") as f: 159 | f.write(yaml.safe_dump(details)) 160 | 161 | 162 | def write_organizational_units(unit: dict, output_dir: str) -> None: 163 | details = unit.get("details") 164 | if details.get("Name") == "Root": 165 | name = details.get("Id") 166 | else: 167 | name = details.get("Name") 168 | 169 | this_output_dir = SEP.join([output_dir, name, "_organizational_units"]) 170 | os.makedirs(this_output_dir, exist_ok=True) 171 | 172 | write_details(details, SEP.join([output_dir, name])) 173 | 174 | for child_unit_id, child_unit in unit.get("organizational_units", {}).items(): 175 | write_organizational_units( 176 | child_unit, 177 | this_output_dir, 178 | ) 179 | 180 | 181 | def import_organization(role_arn: str, root_id: str) -> None: 182 | update_state(role_arn) 183 | state = yaml.safe_load(open(STATE_FILE, "r").read()) 184 | output_dir = "environment" 185 | organizational_units = state.get("organizational_units").get("tree") 186 | by_id = state.get("organizational_units").get("by_id") 187 | 188 | root = organizational_units.get(root_id) 189 | write_organizational_units( 190 | root, 191 | output_dir, 192 | ) 193 | 194 | # TODO: partition the state file by org id 195 | for account_id, account in state.get("accounts").items(): 196 | account_details = account.get("details") 197 | parent_ou_id = account.get("parents")[0].get("Id") 198 | parent_ou_path = by_id.get(parent_ou_id).get("path") 199 | 200 | output_path_parts = [output_dir, root_id] 201 | for parent in parent_ou_path.split("/"): 202 | if parent != "": 203 | output_path_parts += [ 204 | "_organizational_units", 205 | parent, 206 | ] 207 | output_path_parts += [ 208 | "_accounts", 209 | account_details.get("Name"), 210 | ] 211 | output_path = SEP.join(output_path_parts) 212 | 213 | os.makedirs(output_path, exist_ok=True) 214 | with open( 215 | f"{output_path}{SEP}{META_FILE_NAME}", 216 | "w", 217 | ) as f: 218 | f.write(yaml.safe_dump(account_details)) 219 | 220 | 221 | def write_migration( 222 | extension: str, root_id: str, migration_type: str, migration_params: dict 223 | ) -> None: 224 | now = datetime.now() 225 | timestamp = datetime.timestamp(now) 226 | migration_file_name = f"{timestamp}_{migration_type}.yaml" 227 | os.makedirs( 228 | SEP.join(["environment", root_id, "_migrations"]), 229 | exist_ok=True, 230 | ) 231 | with open( 232 | SEP.join(["environment", root_id, "_migrations", migration_file_name]), "w" 233 | ) as f: 234 | f.write( 235 | yaml.safe_dump( 236 | dict( 237 | extension=extension, 238 | migration_type=migration_type, 239 | migration_params=migration_params, 240 | ) 241 | ) 242 | ) 243 | 244 | 245 | def make_migrations(role_arn: str, root_id: str) -> None: 246 | with betterboto_client.CrossAccountClientContextManager( 247 | "organizations", 248 | role_arn, 249 | f"organizations", 250 | ) as orgs_client: 251 | progress = bar.IncrementalBar("Making migrations", max=2) 252 | progress.next() 253 | make_migrations_for_organizational_units(orgs_client, root_id) 254 | progress.next() 255 | make_migrations_for_accounts(orgs_client, root_id) 256 | progress.finish() 257 | 258 | 259 | def make_migrations_for_accounts(organizations, root_id: str) -> None: 260 | """ 261 | Creates migrations for the following account use cases: 262 | - move an account 263 | - - when the remote parent ou exists - ACCOUNT_MOVE 264 | - - when the remote parent ou does not exist - ACCOUNT_MOVE_WITH_NON_EXISTENT_PARENT_OU 265 | :param organizations: 266 | :return: 267 | """ 268 | accounts = get_accounts_folders() 269 | for account_file_path in accounts: 270 | account_name = account_file_path.split(SEP)[-1] 271 | account_details = yaml.safe_load( 272 | open(f"{account_file_path}{SEP}{META_FILE_NAME}", "r").read() 273 | ) 274 | list_parents_single_page_response = organizations.list_parents_single_page( 275 | ChildId=account_details.get("Id") 276 | ).get("Parents") 277 | if len(list_parents_single_page_response) != 1: 278 | raise Exception( 279 | f"{account_details.get('Id')} has {len(list_parents_single_page_response)} parents." 280 | ) 281 | remote_parent_organizational_unit_ou_id = list_parents_single_page_response[ 282 | 0 283 | ].get("Id") 284 | 285 | parent_ou_path_details_file_path = SEP.join( 286 | account_file_path.split(SEP)[0:-2] + [META_FILE_NAME] 287 | ) 288 | 289 | if os.path.exists(parent_ou_path_details_file_path): 290 | local_parent_ou_details = yaml.safe_load( 291 | open(parent_ou_path_details_file_path, "r").read() 292 | ) 293 | local_parent_organizational_unit_ou_id = local_parent_ou_details.get("Id") 294 | 295 | if ( 296 | local_parent_organizational_unit_ou_id 297 | != remote_parent_organizational_unit_ou_id 298 | ): 299 | write_migration( 300 | EXTENSION, 301 | root_id, 302 | migrations.ACCOUNT_MOVE, 303 | dict( 304 | account_id=account_details.get("Id"), 305 | source_parent_id=remote_parent_organizational_unit_ou_id, 306 | destination_parent_id=local_parent_organizational_unit_ou_id, 307 | ), 308 | ) 309 | else: 310 | destination_path = SEP.join( 311 | [""] + parent_ou_path_details_file_path.split(SEP)[3:-1] 312 | ).replace(f"{SEP}_organizational_units", "") 313 | write_migration( 314 | EXTENSION, 315 | root_id, 316 | migrations.ACCOUNT_MOVE_WITH_NON_EXISTENT_PARENT_OU, 317 | dict( 318 | account_id=account_details.get("Id"), 319 | source_parent_id=remote_parent_organizational_unit_ou_id, 320 | destination_path=destination_path, 321 | ), 322 | ) 323 | 324 | 325 | def get_parent_ou_id_for_details_file(details_file_path: str) -> str: 326 | parent_path = SEP.join(details_file_path.split(SEP)[0:-2]) 327 | if os.path.exists(f"{parent_path}{SEP}{META_FILE_NAME}"): 328 | parent_details = yaml.safe_load( 329 | open(f"{parent_path}{SEP}{META_FILE_NAME}", "r").read() 330 | ) 331 | if parent_details.get("Id"): 332 | return parent_details.get("Id") 333 | return None 334 | 335 | 336 | def make_migrations_for_organizational_units(organizations, root_id: str) -> None: 337 | """ 338 | Creates migrations for the following OU use cases: 339 | - add an ou 340 | - - where the remote parent exists - OU_CREATE 341 | - - where the remote parent does not exist yet - OU_CREATE_WITH_NON_EXISTENT_PARENT_OU 342 | - rename an ou 343 | - - where the remote ou existed already - OU_RENAME 344 | 345 | Does not support the following OU use cases: 346 | - delete an ou 347 | - - where the remote ou exists already 348 | - - where the remote ou does not exist already 349 | - move an ou 350 | - - where there is already a remote target ou 351 | - - where there is not already a remote target ou 352 | - - where there is already a remote parent ou 353 | - - where there is not already a remote parent ou 354 | 355 | :param organizations: 356 | :return: 357 | """ 358 | organizational_units_folders = get_organizational_units_folders() 359 | for organizational_unit_folder in organizational_units_folders: 360 | if os.path.exists(SEP.join([organizational_unit_folder, META_FILE_NAME])): 361 | details = yaml.safe_load( 362 | open( 363 | SEP.join([organizational_unit_folder, META_FILE_NAME]), 364 | "r", 365 | ).read() 366 | ) 367 | remote_name = ( 368 | organizations.describe_organizational_unit( 369 | OrganizationalUnitId=details.get("Id") 370 | ) 371 | .get("OrganizationalUnit") 372 | .get("Name") 373 | ) 374 | 375 | local_name = organizational_unit_folder.split(SEP)[-1] 376 | if remote_name != local_name: 377 | write_migration( 378 | EXTENSION, 379 | root_id, 380 | migrations.OU_RENAME, 381 | dict( 382 | name=local_name, 383 | organizational_unit_id=details.get("Id"), 384 | ), 385 | ) 386 | 387 | else: 388 | parent_organizational_unit_folder = SEP.join( 389 | organizational_unit_folder.split(SEP)[0:-2] 390 | ) 391 | new_ou_name = organizational_unit_folder.split(SEP)[-1] 392 | if os.path.exists( 393 | SEP.join([parent_organizational_unit_folder, META_FILE_NAME]) 394 | ): 395 | parent_id = yaml.safe_load( 396 | open( 397 | SEP.join([parent_organizational_unit_folder, META_FILE_NAME]), 398 | "r", 399 | ).read() 400 | ).get("Id") 401 | write_migration( 402 | EXTENSION, 403 | root_id, 404 | migrations.OU_CREATE, 405 | dict(name=new_ou_name, parent_id=parent_id), 406 | ) 407 | else: 408 | parent_ou_path = "/".join( 409 | [""] 410 | + parent_organizational_unit_folder.replace( 411 | "_organizational_units", "" 412 | ) 413 | .replace(f"{SEP}{SEP}", SEP) 414 | .split(SEP)[2:] 415 | ) 416 | write_migration( 417 | EXTENSION, 418 | root_id, 419 | migrations.OU_CREATE_WITH_NON_EXISTENT_PARENT_OU, 420 | dict(name=new_ou_name, parent_ou_path=parent_ou_path), 421 | ) 422 | 423 | 424 | def get_organizational_units_folders() -> list: 425 | return [ 426 | x[0] 427 | for x in os.walk("environment/") 428 | if x[0].split(SEP)[-2] == "_organizational_units" 429 | ] 430 | 431 | 432 | def get_accounts_folders() -> list: 433 | return [x[0] for x in os.walk("environment/") if x[0].split(SEP)[-2] == "_accounts"] 434 | 435 | 436 | def migrate(root_id: str, role_arn: str, ssm_parameter_prefix: str) -> None: 437 | with betterboto_client.CrossAccountClientContextManager( 438 | "ssm", 439 | role_arn, 440 | f"ssm", 441 | ) as ssm: 442 | progress = bar.IncrementalBar( 443 | "Migrating", max=len(os.listdir(f"environment/{root_id}/_migrations")) 444 | ) 445 | for migration_file in sorted(os.listdir(f"environment/{root_id}/_migrations")): 446 | progress.next() 447 | migration_id = migration_file.split(SEP)[-1].replace(".yaml", "") 448 | 449 | try: 450 | ssm.get_parameter( 451 | Name=f"{ssm_parameter_prefix}/migrations/{migration_id}" 452 | ) 453 | click.echo(f" Migration: {migration_id} already run") 454 | except ssm.exceptions.ParameterNotFound: 455 | # click.echo( 456 | # f" Record of migration: {migration_id} being run not found, running now" 457 | # ) 458 | migration = yaml.safe_load( 459 | open( 460 | f"environment/{root_id}/_migrations/{migration_file}", "r" 461 | ).read() 462 | ) 463 | migration_extension = migration.get("extension") 464 | migration_type = migration.get("migration_type") 465 | migration_params = migration.get("migration_params") 466 | 467 | if migration_extension == EXTENSION: 468 | migration_function = migrations.get_function(migration_type) 469 | elif migration_extension == service_control_policies.EXTENSION: 470 | migration_function = ( 471 | service_control_policies.migrations.get_function(migration_type) 472 | ) 473 | elif migration_extension == delegated_administrators.EXTENSION: 474 | migration_function = ( 475 | delegated_administrators.migrations.get_function(migration_type) 476 | ) 477 | else: 478 | raise Exception(f"Unknown extension: {migration_extension}") 479 | 480 | try: 481 | 482 | with betterboto_client.CrossAccountClientContextManager( 483 | "organizations", 484 | role_arn=role_arn, 485 | role_session_name="ou_create", 486 | ) as client: 487 | result, message = migration_function( 488 | root_id, client, **migration_params 489 | ) 490 | except Exception as ex: 491 | result = False 492 | message = "Unhandled error: {0}".format(ex) 493 | 494 | status = "Ok" if result else "FAILED" 495 | click.echo(f"{migration_id}: {status} - {message}") 496 | ssm.put_parameter( 497 | Name=f"{ssm_parameter_prefix}/migrations/{migration_id}", 498 | Description=f"Migration run: {datetime.utcnow()}", 499 | Value=status if result else f"{status}: {message}", 500 | Type="String", 501 | Tags=[ 502 | {"Key": "AWS-Organized:Actor", "Value": "Framework"}, 503 | ], 504 | ) 505 | progress.finish() 506 | 507 | 508 | def prune_metadata() -> None: 509 | accounts = get_accounts_folders() 510 | progress = bar.IncrementalBar( 511 | "Pruning accounts and OU metadata", max=2 512 | ) 513 | progress.next() 514 | for account in accounts: 515 | if os.path.exists(f"{account}/_meta.yaml"): 516 | os.remove(f"{account}/_meta.yaml") 517 | progress.next() 518 | ous = get_organizational_units_folders() 519 | for ou in ous: 520 | if os.path.exists(f"{ou}/_meta.yaml"): 521 | os.remove(f"{ou}/_meta.yaml") 522 | progress.finish() 523 | -------------------------------------------------------------------------------- /aws_organized/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import os 4 | import click 5 | from aws_organized import helpers 6 | from aws_organized import aws_organized 7 | from aws_organized.extensions.service_control_policies import service_control_policies 8 | from aws_organized.extensions.delegated_administrators import delegated_administrators 9 | from betterboto import client as betterboto_client 10 | 11 | 12 | @click.group() 13 | def cli(): 14 | """cli""" 15 | pass 16 | 17 | 18 | @cli.command() 19 | @click.argument("role_arn") 20 | def make_migration_policies(role_arn) -> None: 21 | service_control_policies.make_migration_policies(role_arn) 22 | 23 | 24 | @cli.command() 25 | @click.argument("role_arn") 26 | def apply_migration_policies(role_arn) -> None: 27 | service_control_policies.apply_migration_policies(role_arn) 28 | 29 | 30 | @cli.command() 31 | @click.argument("role_arn") 32 | def import_organization(role_arn): 33 | with betterboto_client.CrossAccountClientContextManager( 34 | "organizations", role_arn, f"organizations" 35 | ) as organizations: 36 | for root in organizations.list_roots_single_page().get("Roots", []): 37 | os.makedirs(f"environment/{root.get('Id')}", exist_ok=True) 38 | roots = os.listdir("environment") 39 | for root_id in roots: 40 | if root_id in ["migrations", "Policies", "policies_migration"]: 41 | continue 42 | click.echo(f"Processing root_id: {root_id}") 43 | aws_organized.import_organization(role_arn, root_id) 44 | service_control_policies.import_organization_policies(role_arn, root_id) 45 | delegated_administrators.import_organization(role_arn, root_id) 46 | 47 | 48 | @cli.command() 49 | @click.option("--role-name", default="ImportOrganizationRole") 50 | @click.option("--path", default="/AWSOrganized/") 51 | @click.option("--assuming-resource", default="root") 52 | @click.option("--output-format", default="yaml") 53 | @click.argument("assuming-account-id") 54 | def generate_import_organization_role_template( 55 | role_name: str, 56 | path: str, 57 | assuming_resource: str, 58 | output_format: str, 59 | assuming_account_id: str, 60 | ): 61 | t = helpers.generate_import_organization_role_template( 62 | role_name, path, assuming_account_id, assuming_resource 63 | ) 64 | if output_format.lower() == "json": 65 | click.echo(t.to_json()) 66 | else: 67 | click.echo(t.to_yaml()) 68 | 69 | 70 | @cli.command() 71 | @click.option("--role-name", default="ImportOrganizationRole") 72 | @click.option("--path", default="/AWSOrganized/") 73 | @click.option("--assuming-resource", default="root") 74 | @click.argument("assuming-account-id") 75 | def provision_import_organization_role_stack( 76 | role_name: str, path: str, assuming_resource: str, assuming_account_id: str 77 | ): 78 | helpers.provision_import_organization_role_stack( 79 | role_name, path, assuming_account_id, assuming_resource 80 | ) 81 | 82 | 83 | @cli.command() 84 | @click.argument("role_arn") 85 | def make_migrations(role_arn): 86 | for root_id in os.listdir("environment"): 87 | if root_id in ["migrations", "Policies", "policies_migration"]: 88 | continue 89 | click.echo(f"Processing root_id: {root_id}") 90 | aws_organized.make_migrations(role_arn, root_id) 91 | service_control_policies.make_migrations(role_arn, root_id) 92 | delegated_administrators.make_migrations(role_arn, root_id) 93 | 94 | 95 | @cli.command() 96 | @click.option("--role-name", default="MakeMigrationsRole") 97 | @click.option("--path", default="/AWSOrganized/") 98 | @click.option("--assuming-resource", default="root") 99 | @click.option("--output-format", default="yaml") 100 | @click.argument("assuming-account-id") 101 | def generate_make_migrations_role_template( 102 | role_name: str, 103 | path: str, 104 | assuming_resource: str, 105 | output_format: str, 106 | assuming_account_id: str, 107 | ): 108 | t = helpers.generate_make_migrations_role_template( 109 | role_name, path, assuming_account_id, assuming_resource 110 | ) 111 | if output_format.lower() == "json": 112 | click.echo(t.to_json()) 113 | else: 114 | click.echo(t.to_yaml()) 115 | 116 | 117 | @cli.command() 118 | @click.option("--role-name", default="MakeMigrationsRole") 119 | @click.option("--path", default="/AWSOrganized/") 120 | @click.option("--assuming-resource", default="root") 121 | @click.argument("assuming-account-id") 122 | def provision_make_migrations_role_stack( 123 | role_name: str, path: str, assuming_resource: str, assuming_account_id: str 124 | ): 125 | helpers.provision_make_migrations_role_stack( 126 | role_name, path, assuming_account_id, assuming_resource 127 | ) 128 | 129 | 130 | @cli.command() 131 | @click.option("--ssm-parameter-prefix", default="/-AWS-Organized") 132 | @click.argument("role_arn") 133 | def migrate(ssm_parameter_prefix: str, role_arn: str): 134 | for root_id in os.listdir("environment"): 135 | aws_organized.migrate(root_id, role_arn, ssm_parameter_prefix) 136 | 137 | 138 | @cli.command() 139 | @click.option("--role-name", default="MigrateRole") 140 | @click.option("--path", default="/AWSOrganized/") 141 | @click.option("--assuming-resource", default="root") 142 | @click.option("--ssm-parameter-prefix", default="/-AWS-Organized") 143 | @click.option("--output-format", default="yaml") 144 | @click.argument("assuming-account-id") 145 | def generate_migrate_role_template( 146 | role_name: str, 147 | path: str, 148 | assuming_resource: str, 149 | ssm_parameter_prefix: str, 150 | output_format: str, 151 | assuming_account_id: str, 152 | ): 153 | t = helpers.generate_migrate_role_template( 154 | role_name, path, assuming_account_id, assuming_resource, ssm_parameter_prefix 155 | ) 156 | if output_format.lower() == "json": 157 | click.echo(t.to_json()) 158 | else: 159 | click.echo(t.to_yaml(clean_up=True)) 160 | 161 | 162 | @cli.command() 163 | @click.option("--role-name", default="MigrateRole") 164 | @click.option("--path", default="/AWSOrganized/") 165 | @click.option("--assuming-resource", default="root") 166 | @click.option("--ssm-parameter-prefix", default="/-AWS-Organized") 167 | @click.argument("assuming-account-id") 168 | def provision_migrate_role_stack( 169 | role_name: str, 170 | path: str, 171 | assuming_resource: str, 172 | assuming_account_id: str, 173 | ssm_parameter_prefix: str, 174 | ): 175 | helpers.provision_migrate_role_stack( 176 | role_name, path, assuming_account_id, assuming_resource, ssm_parameter_prefix 177 | ) 178 | 179 | 180 | @cli.command() 181 | @click.option("--codepipeline-role-name", default="CodePipelineRole") 182 | @click.option("--codepipeline-role-path", default="/AWSOrganized/") 183 | @click.option("--codebuild-role-name", default="CodeBuildRole") 184 | @click.option("--codebuild-role-path", default="/AWSOrganized/") 185 | @click.option("--ssm-parameter-prefix", default="/-AWS-Organized") 186 | @click.option("--scm-provider", default="CodeCommit") 187 | @click.option("--scm-connection-arn") 188 | @click.option("--scm-full-repository-id", default="AWS-Organized-environment") 189 | @click.option("--scm-branch-name", default="main") 190 | @click.option("--scm-bucket-name") 191 | @click.option("--scm-object-key", default="environment.zip") 192 | @click.option("--scm-skip-creation-of-repo", default=False) 193 | @click.option("--output_format", default="yaml") 194 | @click.argument("migrate-role-arn") 195 | def generate_codepipeline_template( 196 | codepipeline_role_name: str, 197 | codepipeline_role_path: str, 198 | codebuild_role_name: str, 199 | codebuild_role_path: str, 200 | ssm_parameter_prefix: str, 201 | scm_provider: str, 202 | scm_connection_arn: str, 203 | scm_full_repository_id: str, 204 | scm_branch_name: str, 205 | scm_bucket_name: str, 206 | scm_object_key: str, 207 | scm_skip_creation_of_repo: str, 208 | output_format: str, 209 | migrate_role_arn: str, 210 | ): 211 | t = helpers.generate_codepipeline_template( 212 | codepipeline_role_name, 213 | codepipeline_role_path, 214 | codebuild_role_name, 215 | codebuild_role_path, 216 | ssm_parameter_prefix, 217 | scm_provider, 218 | scm_connection_arn, 219 | scm_full_repository_id, 220 | scm_branch_name, 221 | scm_bucket_name, 222 | scm_object_key, 223 | scm_skip_creation_of_repo, 224 | migrate_role_arn, 225 | ) 226 | if output_format.lower() == "json": 227 | click.echo(t.to_json()) 228 | else: 229 | click.echo(t.to_yaml()) 230 | 231 | 232 | @cli.command() 233 | @click.option("--codepipeline-role-name", default="AWSOrganizedCodePipelineRole") 234 | @click.option("--codepipeline-role-path", default="/AWSOrganized/") 235 | @click.option("--codebuild-role-name", default="AWSOrganizedCodeBuildRole") 236 | @click.option("--codebuild-role-path", default="/AWSOrganized/") 237 | @click.option("--ssm-parameter-prefix", default="/-AWS-Organized") 238 | @click.option("--scm-provider", default="CodeCommit") 239 | @click.option("--scm-connection-arn") 240 | @click.option("--scm-full-repository-id", default="AWS-Organized-environment") 241 | @click.option("--scm-branch-name", default="main") 242 | @click.option("--scm-bucket-name") 243 | @click.option("--scm-object-key") 244 | @click.option("--scm-skip-creation-of-repo", default=False) 245 | @click.argument("migrate-role-arn") 246 | def provision_codepipeline_stack( 247 | codepipeline_role_name: str, 248 | codepipeline_role_path: str, 249 | codebuild_role_name: str, 250 | codebuild_role_path: str, 251 | ssm_parameter_prefix: str, 252 | scm_provider: str, 253 | scm_connection_arn: str, 254 | scm_full_repository_id: str, 255 | scm_branch_name: str, 256 | scm_bucket_name: str, 257 | scm_object_key: str, 258 | scm_skip_creation_of_repo: str, 259 | migrate_role_arn: str, 260 | ): 261 | helpers.provision_codepipeline_stack( 262 | codepipeline_role_name, 263 | codepipeline_role_path, 264 | codebuild_role_name, 265 | codebuild_role_path, 266 | ssm_parameter_prefix, 267 | scm_provider, 268 | scm_connection_arn, 269 | scm_full_repository_id, 270 | scm_branch_name, 271 | scm_bucket_name, 272 | scm_object_key, 273 | scm_skip_creation_of_repo, 274 | migrate_role_arn, 275 | ) 276 | 277 | @cli.command() 278 | def prune_metadata(): 279 | for root_id in os.listdir("environment"): 280 | if root_id in ["migrations", "Policies", "policies_migration"]: 281 | continue 282 | click.echo(f"Processing root_id: {root_id}") 283 | aws_organized.prune_metadata() 284 | service_control_policies.prune_metadata(root_id) 285 | delegated_administrators.prune_metadata(root_id) 286 | 287 | if __name__ == "__main__": 288 | cli() 289 | -------------------------------------------------------------------------------- /aws_organized/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /aws_organized/extensions/delegated_administrators/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /aws_organized/extensions/delegated_administrators/delegated_administrators.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import glob 4 | import collections 5 | from datetime import datetime 6 | import json 7 | import yaml 8 | import click 9 | import os 10 | from betterboto import client as betterboto_client 11 | from . import migrations 12 | from progress import bar 13 | 14 | SERVICE_CONTROL_POLICY = "SERVICE_CONTROL_POLICY" 15 | ORGANIZATIONAL_UNIT = "ORGANIZATIONAL_UNIT" 16 | ACCOUNT = "ACCOUNT" 17 | SEP = os.path.sep 18 | EXTENSION = "delegated_administrators" 19 | 20 | 21 | ServicePrincipal = "ServicePrincipal" 22 | 23 | 24 | def save_all_policies_from_org(root_id: str, organizations) -> None: 25 | all_service_control_policies_in_org = organizations.list_policies_single_page( 26 | Filter=SERVICE_CONTROL_POLICY 27 | ).get("Policies") 28 | for policy in all_service_control_policies_in_org: 29 | described_policy = organizations.describe_policy(PolicyId=policy.get("Id")).get( 30 | "Policy" 31 | ) 32 | policy_name = described_policy.get("PolicySummary").get("Name") 33 | path = f"environment/{root_id}/_policies/service_control_policies/{policy_name}" 34 | os.makedirs(path, exist_ok=True) 35 | open(f"{path}/policy.json", "w").write( 36 | json.dumps(json.loads(described_policy.get("Content")), indent=4) 37 | ) 38 | del described_policy["Content"] 39 | open(f"{path}/_meta.yaml", "w").write( 40 | yaml.safe_dump(described_policy.get("PolicySummary")) 41 | ) 42 | 43 | 44 | def get_id_for_entity_from_path(entity_path: str) -> str: 45 | return yaml.safe_load(open(entity_path, "r").read()).get("Id") 46 | 47 | 48 | def write_policy_for_given_entity_path( 49 | entity_path: str, application_method: str, policies: dict 50 | ) -> None: 51 | existing = dict() 52 | existing[application_method] = policies 53 | open( 54 | entity_path.replace("_meta.yaml", "_service_control_policies.yaml"), "w" 55 | ).write(yaml.safe_dump(existing)) 56 | 57 | 58 | def save_policies_for_entity(entity_path: str, organizations) -> None: 59 | entity_path_parts = entity_path.split(SEP) 60 | entity_type = entity_path_parts[-3] 61 | click.echo(f"{entity_type} - {entity_path}") 62 | policies = organizations.list_policies_for_target_single_page( 63 | TargetId=get_id_for_entity_from_path(entity_path), Filter=SERVICE_CONTROL_POLICY 64 | ).get("Policies") 65 | write_policy_for_given_entity_path(entity_path, "Attached", policies) 66 | 67 | 68 | def save_policies_for_each_entity(entities: list, organizations) -> None: 69 | for entity in entities: 70 | save_policies_for_entity(entity, organizations) 71 | 72 | 73 | def get_path_for_ou(root_id: str, ou: dict) -> str: 74 | ou_path = ou.get("path") 75 | parts = ou_path.split("/")[1:] 76 | file_path = ["environment", root_id] 77 | for part in parts: 78 | file_path.append("_organizational_units") 79 | file_path.append(part) 80 | return SEP.join(file_path) 81 | 82 | 83 | def save_targets_for_policy(root_id, organizations) -> None: 84 | policies = glob.glob( 85 | f"environment/{root_id}/_policies/service_control_policies/*/*.yaml" 86 | ) 87 | state = yaml.safe_load(open("state.yaml", "r").read()) 88 | progress = bar.IncrementalBar("Importing policies", max=len(policies)) 89 | for policy_file in policies: 90 | progress.next() 91 | policy = yaml.safe_load(open(policy_file, "r").read()) 92 | policy_id = policy.get("Id") 93 | targets = organizations.list_targets_for_policy_single_page( 94 | PolicyId=policy_id 95 | ).get("Targets", []) 96 | for target in targets: 97 | inherited = list() 98 | if target.get("Type") == ACCOUNT: 99 | account = ( 100 | state.get("accounts").get(target.get("TargetId")).get("details") 101 | ) 102 | attached = glob.glob( 103 | f"environment/{root_id}/**/{account.get('Name')}/_meta.yaml", 104 | recursive=True, 105 | ) 106 | elif target.get("Type") == ORGANIZATIONAL_UNIT: 107 | ou = ( 108 | state.get("organizational_units") 109 | .get("by_id") 110 | .get(target.get("TargetId")) 111 | ) 112 | path_to_ou = get_path_for_ou(root_id, ou) 113 | attached = glob.glob(f"{path_to_ou}/_meta.yaml") 114 | inherited += glob.glob( 115 | f"{path_to_ou}/_accounts/**/_meta.yaml", recursive=True 116 | ) 117 | inherited += glob.glob( 118 | f"{path_to_ou}/_organizational_units/**/_meta.yaml", recursive=True 119 | ) 120 | elif target.get("Type") == "ROOT": 121 | attached = glob.glob( 122 | f"environment/{root_id}/_meta.yaml", recursive=True 123 | ) 124 | inherited += glob.glob( 125 | f"environment/{root_id}/_meta.yaml", recursive=True 126 | ) 127 | else: 128 | raise Exception(f"Not handled type: {target.get('Type')}") 129 | if attached: 130 | assert ( 131 | len(attached) == 1 132 | ), f"mapping to attached entity found {len(attached)} entities for {target}" 133 | attached = attached[0] 134 | output_path = attached.replace( 135 | "_meta.yaml", "_service_control_policies.yaml" 136 | ) 137 | output = dict(Attached=list(), Inherited=list()) 138 | if os.path.exists(output_path): 139 | output = yaml.safe_load(open(output_path, "r").read()) 140 | output["Attached"].append(policy) 141 | open(output_path, "w").write(yaml.safe_dump(output)) 142 | for thing in inherited: 143 | output_path = thing.replace( 144 | "_meta.yaml", "_service_control_policies.yaml" 145 | ) 146 | output = dict(Attached=list(), Inherited=list()) 147 | if os.path.exists(output_path): 148 | output = yaml.safe_load(open(output_path, "r").read()) 149 | i = dict(Source=target.get("Name")) 150 | i.update(policy) 151 | output["Inherited"].append(i) 152 | open(output_path, "w").write(yaml.safe_dump(output)) 153 | progress.finish() 154 | 155 | 156 | def remove_any_existing_records(root_id: str) -> None: 157 | policies = glob.glob( 158 | f"environment/{root_id}/**/_delegated_administrators.yaml", recursive=True 159 | ) 160 | for policy in policies: 161 | os.remove(policy) 162 | 163 | 164 | def import_organization(role_arn, root_id) -> None: 165 | remove_any_existing_records(root_id) 166 | with betterboto_client.CrossAccountClientContextManager( 167 | "organizations", role_arn, f"organizations" 168 | ) as organizations: 169 | delegated_administrators = ( 170 | organizations.list_delegated_administrators_single_page().get( 171 | "DelegatedAdministrators", [] 172 | ) 173 | ) 174 | progress = bar.IncrementalBar( 175 | "Importing Delegated Administrators", max=len(delegated_administrators) 176 | ) 177 | for delegated_administrator in delegated_administrators: 178 | progress.next() 179 | account_id = delegated_administrator.get("Id") 180 | account_name = delegated_administrator.get("Name") 181 | delegated_services = ( 182 | organizations.list_delegated_services_for_account_single_page( 183 | AccountId=account_id 184 | ).get("DelegatedServices") 185 | ) 186 | 187 | account_file = glob.glob( 188 | f"environment/{root_id}/**/{account_name}/_meta.yaml", 189 | recursive=True, 190 | ) 191 | assert ( 192 | len(account_file) == 1 193 | ), "found more or less than 1 account_meta file when searching by name" 194 | delegated_administrators_file = account_file[0].replace( 195 | "_meta", "_delegated_administrators" 196 | ) 197 | open(delegated_administrators_file, "w").write( 198 | yaml.safe_dump(delegated_services) 199 | ) 200 | 201 | 202 | def write_migration( 203 | extension: str, root_id: str, migration_type: str, migration_params: dict 204 | ) -> None: 205 | now = datetime.now() 206 | timestamp = datetime.timestamp(now) 207 | migration_file_name = f"{timestamp}_{migration_type}.yaml" 208 | os.makedirs(SEP.join(["environment", root_id, "_migrations"]), exist_ok=True) 209 | with open( 210 | SEP.join(["environment", root_id, "_migrations", migration_file_name]), "w" 211 | ) as f: 212 | f.write( 213 | yaml.safe_dump( 214 | dict( 215 | extension=extension, 216 | migration_type=migration_type, 217 | migration_params=migration_params, 218 | ) 219 | ) 220 | ) 221 | 222 | 223 | def check_attachment(root_id: str, policy_file_path: str, organizations) -> None: 224 | local_policies = yaml.safe_load(open(policy_file_path, "r").read()) 225 | meta = yaml.safe_load( 226 | open( 227 | policy_file_path.replace("_service_control_policies.yaml", "_meta.yaml"), 228 | "r", 229 | ).read() 230 | ) 231 | remote_policies = organizations.list_policies_for_target_single_page( 232 | TargetId=meta.get("Id"), Filter=SERVICE_CONTROL_POLICY 233 | ).get("Policies") 234 | for local_policy in local_policies.get("Attached", []): 235 | found = False 236 | for remote_policy in remote_policies: 237 | if local_policy.get("Name") == remote_policy.get("Name"): 238 | found = True 239 | if not found: 240 | write_migration( 241 | EXTENSION, 242 | root_id, 243 | migrations.POLICY_ATTACH, 244 | dict(policy_name=local_policy.get("Name"), target_id=meta.get("Id")), 245 | ) 246 | 247 | 248 | def check_existing(root_id: str, organizations) -> None: 249 | administrators = glob.glob( 250 | f"environment/{root_id}/**/_delegated_administrators.yaml", recursive=True 251 | ) 252 | for administrator in administrators: 253 | local_delegated = yaml.safe_load(open(administrator, "r").read()) 254 | account_details = yaml.safe_load( 255 | open( 256 | administrator.replace("_delegated_administrators", "_meta"), "r" 257 | ).read() 258 | ) 259 | try: 260 | response = organizations.list_delegated_services_for_account_single_page( 261 | AccountId=account_details.get("Id") 262 | ) 263 | remote_delegated_as_dict = dict() 264 | for delegated_service in response.get("DelegatedServices", []): 265 | remote_delegated_as_dict[ 266 | delegated_service.get(ServicePrincipal) 267 | ] = delegated_service 268 | 269 | local_delegated_as_dict = dict() 270 | for administrator_detail in local_delegated: 271 | local_delegated_as_dict[ 272 | administrator_detail.get(ServicePrincipal) 273 | ] = administrator_detail 274 | if ( 275 | remote_delegated_as_dict.get( 276 | administrator_detail.get(ServicePrincipal) 277 | ) 278 | is None 279 | ): 280 | write_migration( 281 | EXTENSION, 282 | root_id, 283 | migrations.REGISTER_DELEGATED_ADMINISTRATOR, 284 | dict( 285 | account_id=account_details.get("Id"), 286 | service_principal=administrator_detail.get( 287 | ServicePrincipal 288 | ), 289 | ), 290 | ) 291 | for delegated_service in response.get("DelegatedServices", []): 292 | if ( 293 | local_delegated_as_dict.get(delegated_service.get(ServicePrincipal)) 294 | is None 295 | ): 296 | write_migration( 297 | EXTENSION, 298 | root_id, 299 | migrations.DEREGISTER_DELEGATED_ADMINISTRATOR, 300 | dict( 301 | account_id=account_details.get("Id"), 302 | service_principal=delegated_service.get(ServicePrincipal), 303 | ), 304 | ) 305 | 306 | except organizations.exceptions.AccountNotRegisteredException: 307 | for administrator_detail in local_delegated: 308 | write_migration( 309 | EXTENSION, 310 | root_id, 311 | migrations.REGISTER_DELEGATED_ADMINISTRATOR, 312 | dict( 313 | account_id=account_details.get("Id"), 314 | service_principal=administrator_detail.get(ServicePrincipal), 315 | ), 316 | ) 317 | 318 | 319 | def make_migrations(role_arn: str, root_id: str) -> None: 320 | with betterboto_client.CrossAccountClientContextManager( 321 | "organizations", role_arn, f"organizations" 322 | ) as organizations: 323 | check_existing(root_id, organizations) 324 | 325 | 326 | 327 | def prune_metadata(root_id: str) -> None: 328 | policies = glob.glob( 329 | f"environment/{root_id}/**/_delegated_administrators.yaml", recursive=True 330 | ) 331 | progress = bar.IncrementalBar( 332 | "Pruning Delegated Administrator metadata", max=len(policies) 333 | ) 334 | for policy in policies: 335 | progress.next() 336 | p = yaml.safe_load(open(policy, 'r').read()) 337 | new_delegated_administrators = list() 338 | for a in p: 339 | new_delegated_administrators.append(dict(ServicePrincipal=a.get("ServicePrincipal"))) 340 | open(policy, 'w').write(yaml.safe_dump(new_delegated_administrators)) 341 | progress.finish() 342 | -------------------------------------------------------------------------------- /aws_organized/extensions/delegated_administrators/migrations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Callable, Tuple 4 | import yaml 5 | import sys 6 | import botocore 7 | import os 8 | 9 | REGISTER_DELEGATED_ADMINISTRATOR = "REGISTER_DELEGATED_ADMINISTRATOR" 10 | DEREGISTER_DELEGATED_ADMINISTRATOR = "DEREGISTER_DELEGATED_ADMINISTRATOR" 11 | 12 | OK = "Ok" 13 | MigrationResult = Tuple[bool, str] 14 | 15 | 16 | def register_delegated_administrator( 17 | root_id: str, 18 | client, 19 | account_id: str, 20 | service_principal: str, 21 | ) -> MigrationResult: 22 | try: 23 | client.register_delegated_administrator( 24 | AccountId=account_id, 25 | ServicePrincipal=service_principal, 26 | ) 27 | except botocore.exceptions.ClientError as error: 28 | message = error.response["Error"]["Message"] 29 | return False, message 30 | except: 31 | message = sys.exc_info()[0] 32 | return False, "{0}".format(message) 33 | return True, OK 34 | 35 | 36 | def deregister_delegated_administrator( 37 | root_id: str, 38 | client, 39 | account_id: str, 40 | service_principal: str, 41 | ) -> MigrationResult: 42 | try: 43 | client.deregister_delegated_administrator( 44 | AccountId=account_id, 45 | ServicePrincipal=service_principal, 46 | ) 47 | except botocore.exceptions.ClientError as error: 48 | message = error.response["Error"]["Message"] 49 | return False, message 50 | except: 51 | message = sys.exc_info()[0] 52 | return False, "{0}".format(message) 53 | return True, OK 54 | 55 | 56 | def get_function(migration_name) -> Callable[..., MigrationResult]: 57 | return { 58 | REGISTER_DELEGATED_ADMINISTRATOR: register_delegated_administrator, 59 | DEREGISTER_DELEGATED_ADMINISTRATOR: deregister_delegated_administrator, 60 | }.get(migration_name) 61 | -------------------------------------------------------------------------------- /aws_organized/extensions/service_control_policies/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /aws_organized/extensions/service_control_policies/migrations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | from typing import Callable, Tuple 4 | import yaml 5 | import sys 6 | import botocore 7 | import os 8 | 9 | POLICY_ATTACH = "POLICY_ATTACH" 10 | POLICY_DETAILS_UPDATE = "POLICY_DETAILS_UPDATE" 11 | POLICY_CONTENT_UPDATE = "POLICY_CONTENT_UPDATE" 12 | POLICY_CREATE = "POLICY_CREATE" 13 | 14 | OK = "Ok" 15 | MigrationResult = Tuple[bool, str] 16 | 17 | 18 | def policy_details_update( 19 | root_id: str, 20 | client, 21 | id: str, 22 | name: str, 23 | description: str, 24 | ) -> MigrationResult: 25 | try: 26 | client.update_policy( 27 | PolicyId=id, 28 | Name=name, 29 | Description=description, 30 | ) 31 | except botocore.exceptions.ClientError as error: 32 | message = error.response["Error"]["Message"] 33 | return False, message 34 | except: 35 | message = sys.exc_info()[0] 36 | return False, "{0}".format(message) 37 | return True, OK 38 | 39 | 40 | def policy_content_update( 41 | root_id: str, client, id: str, content: str 42 | ) -> MigrationResult: 43 | try: 44 | client.update_policy( 45 | PolicyId=id, 46 | Content=content, 47 | ) 48 | except botocore.exceptions.ClientError as error: 49 | message = error.response["Error"]["Message"] 50 | return False, message 51 | except: 52 | message = sys.exc_info()[0] 53 | return False, "{0}".format(message) 54 | return True, OK 55 | 56 | 57 | def policy_create( 58 | root_id: str, 59 | client, 60 | name: str, 61 | content: str, 62 | ) -> MigrationResult: 63 | try: 64 | client.create_policy( 65 | Content=content, 66 | Description="-", 67 | Name=name, 68 | Type="SERVICE_CONTROL_POLICY", 69 | ) 70 | 71 | except botocore.exceptions.ClientError as error: 72 | message = error.response["Error"]["Message"] 73 | return False, message 74 | except: 75 | message = sys.exc_info()[0] 76 | return False, "{0}".format(message) 77 | return True, OK 78 | 79 | 80 | def policy_attach( 81 | root_id: str, 82 | client, 83 | policy_name: str, 84 | target_id: str, 85 | ) -> MigrationResult: 86 | meta_file = f"environment/{root_id}/_policies/service_control_policies/{policy_name}/_meta.yaml" 87 | if os.path.exists(meta_file): 88 | policy_to_use = yaml.safe_load( 89 | open( 90 | meta_file, 91 | "r", 92 | ).read() 93 | ) 94 | else: 95 | for policy in client.list_policies_single_page( 96 | Filter="SERVICE_CONTROL_POLICY" 97 | ).get("Policies", []): 98 | if policy.get("Name") == policy_name: 99 | policy_to_use = policy 100 | break 101 | try: 102 | client.attach_policy( 103 | PolicyId=policy_to_use.get("Id"), 104 | TargetId=target_id, 105 | ) 106 | 107 | except botocore.exceptions.ClientError as error: 108 | message = error.response["Error"]["Message"] 109 | return False, message 110 | except: 111 | message = sys.exc_info()[0] 112 | return False, "{0}".format(message) 113 | return True, OK 114 | 115 | 116 | def get_function(migration_name) -> Callable[..., MigrationResult]: 117 | return { 118 | POLICY_DETAILS_UPDATE: policy_details_update, 119 | POLICY_CONTENT_UPDATE: policy_content_update, 120 | POLICY_CREATE: policy_create, 121 | POLICY_ATTACH: policy_attach, 122 | }.get(migration_name) 123 | -------------------------------------------------------------------------------- /aws_organized/extensions/service_control_policies/service_control_policies.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import glob 4 | from datetime import datetime 5 | import json 6 | import yaml 7 | import click 8 | import os 9 | from betterboto import client as betterboto_client 10 | from . import migrations 11 | from progress import bar 12 | 13 | SERVICE_CONTROL_POLICY = "SERVICE_CONTROL_POLICY" 14 | ORGANIZATIONAL_UNIT = "ORGANIZATIONAL_UNIT" 15 | ACCOUNT = "ACCOUNT" 16 | SEP = os.path.sep 17 | EXTENSION = "service_control_policies" 18 | 19 | 20 | def save_all_policies_from_org(root_id: str, organizations) -> None: 21 | all_service_control_policies_in_org = organizations.list_policies_single_page( 22 | Filter=SERVICE_CONTROL_POLICY 23 | ).get("Policies") 24 | for policy in all_service_control_policies_in_org: 25 | described_policy = organizations.describe_policy(PolicyId=policy.get("Id")).get( 26 | "Policy" 27 | ) 28 | policy_name = described_policy.get("PolicySummary").get("Name") 29 | path = f"environment/{root_id}/_policies/service_control_policies/{policy_name}" 30 | os.makedirs(path, exist_ok=True) 31 | open(f"{path}/policy.json", "w").write( 32 | json.dumps(json.loads(described_policy.get("Content")), indent=4) 33 | ) 34 | del described_policy["Content"] 35 | open(f"{path}/_meta.yaml", "w").write( 36 | yaml.safe_dump(described_policy.get("PolicySummary")) 37 | ) 38 | 39 | 40 | def get_id_for_entity_from_path(entity_path: str) -> str: 41 | return yaml.safe_load(open(entity_path, "r").read()).get("Id") 42 | 43 | 44 | def write_policy_for_given_entity_path( 45 | entity_path: str, application_method: str, policies: dict 46 | ) -> None: 47 | existing = dict() 48 | existing[application_method] = policies 49 | open( 50 | entity_path.replace("_meta.yaml", "_service_control_policies.yaml"), "w" 51 | ).write(yaml.safe_dump(existing)) 52 | 53 | 54 | def save_policies_for_entity(entity_path: str, organizations) -> None: 55 | entity_path_parts = entity_path.split(SEP) 56 | entity_type = entity_path_parts[-3] 57 | click.echo(f"{entity_type} - {entity_path}") 58 | policies = organizations.list_policies_for_target_single_page( 59 | TargetId=get_id_for_entity_from_path(entity_path), Filter=SERVICE_CONTROL_POLICY 60 | ).get("Policies") 61 | write_policy_for_given_entity_path(entity_path, "Attached", policies) 62 | 63 | 64 | def save_policies_for_each_entity(entities: list, organizations) -> None: 65 | for entity in entities: 66 | save_policies_for_entity(entity, organizations) 67 | 68 | 69 | def get_path_for_ou(root_id: str, ou: dict) -> str: 70 | ou_path = ou.get("path") 71 | parts = ou_path.split("/")[1:] 72 | file_path = ["environment", root_id] 73 | for part in parts: 74 | file_path.append("_organizational_units") 75 | file_path.append(part) 76 | return SEP.join(file_path) 77 | 78 | 79 | def save_targets_for_policy(root_id, organizations) -> None: 80 | policies = glob.glob( 81 | f"environment/{root_id}/_policies/service_control_policies/*/*.yaml" 82 | ) 83 | state = yaml.safe_load(open("state.yaml", "r").read()) 84 | progress = bar.IncrementalBar("Importing policies", max=len(policies)) 85 | for policy_file in policies: 86 | progress.next() 87 | policy = yaml.safe_load(open(policy_file, "r").read()) 88 | policy_id = policy.get("Id") 89 | targets = organizations.list_targets_for_policy_single_page( 90 | PolicyId=policy_id 91 | ).get("Targets", []) 92 | for target in targets: 93 | inherited = list() 94 | if target.get("Type") == ACCOUNT: 95 | account = ( 96 | state.get("accounts").get(target.get("TargetId")).get("details") 97 | ) 98 | attached = glob.glob( 99 | f"environment/{root_id}/**/{account.get('Name')}/_meta.yaml", 100 | recursive=True, 101 | ) 102 | elif target.get("Type") == ORGANIZATIONAL_UNIT: 103 | ou = ( 104 | state.get("organizational_units") 105 | .get("by_id") 106 | .get(target.get("TargetId")) 107 | ) 108 | path_to_ou = get_path_for_ou(root_id, ou) 109 | attached = glob.glob(f"{path_to_ou}/_meta.yaml") 110 | inherited += glob.glob( 111 | f"{path_to_ou}/_accounts/**/_meta.yaml", recursive=True 112 | ) 113 | inherited += glob.glob( 114 | f"{path_to_ou}/_organizational_units/**/_meta.yaml", recursive=True 115 | ) 116 | elif target.get("Type") == "ROOT": 117 | attached = glob.glob( 118 | f"environment/{root_id}/_meta.yaml", recursive=True 119 | ) 120 | inherited += glob.glob( 121 | f"environment/{root_id}/_meta.yaml", recursive=True 122 | ) 123 | else: 124 | raise Exception(f"Not handled type: {target.get('Type')}") 125 | 126 | really_attached = list() 127 | for attach in attached: 128 | a = yaml.safe_load(open(attach, 'r').read()) 129 | if a.get("Id") == target.get("TargetId"): 130 | really_attached.append(attach) 131 | 132 | if really_attached: 133 | assert ( 134 | len(really_attached) == 1 135 | ), f"mapping to attached entity found {len(really_attached)} entities for {target}" 136 | really_attached = really_attached[0] 137 | output_path = really_attached.replace( 138 | "_meta.yaml", "_service_control_policies.yaml" 139 | ) 140 | output = dict(Attached=list(), Inherited=list()) 141 | if os.path.exists(output_path): 142 | output = yaml.safe_load(open(output_path, "r").read()) 143 | output["Attached"].append(policy) 144 | open(output_path, "w").write(yaml.safe_dump(output)) 145 | for thing in inherited: 146 | output_path = thing.replace( 147 | "_meta.yaml", "_service_control_policies.yaml" 148 | ) 149 | output = dict(Attached=list(), Inherited=list()) 150 | if os.path.exists(output_path): 151 | output = yaml.safe_load(open(output_path, "r").read()) 152 | i = dict(Source=target.get("Name")) 153 | i.update(policy) 154 | output["Inherited"].append(i) 155 | open(output_path, "w").write(yaml.safe_dump(output)) 156 | progress.finish() 157 | 158 | 159 | def remove_any_existing_policy_records(root_id: str) -> None: 160 | policies = glob.glob( 161 | f"environment/{root_id}/**/_service_control_policies.yaml", recursive=True 162 | ) 163 | for policy in policies: 164 | os.remove(policy) 165 | 166 | 167 | def import_organization_policies(role_arn, root_id) -> None: 168 | with betterboto_client.CrossAccountClientContextManager( 169 | "organizations", role_arn, f"organizations" 170 | ) as organizations: 171 | progress = bar.IncrementalBar("Importing SCPs", max=4) 172 | progress.next() 173 | remove_any_existing_policy_records(root_id) 174 | progress.next() 175 | save_all_policies_from_org(root_id, organizations) 176 | progress.next() 177 | save_targets_for_policy(root_id, organizations) 178 | progress.next() 179 | progress.finish() 180 | 181 | 182 | def check_policies(root_id: str, organizations) -> None: 183 | scps_path = ( 184 | f"environment/{root_id}/_policies/service_control_policies/*/policy.json" 185 | ) 186 | for policy_content_path in glob.glob(scps_path): 187 | policy_file_path = policy_content_path.replace("policy.json", "_meta.yaml") 188 | if os.path.exists(policy_file_path): 189 | local_policy = yaml.safe_load(open(policy_file_path, "r").read()) 190 | if local_policy.get("AwsManaged"): 191 | continue 192 | p = organizations.describe_policy(PolicyId=local_policy.get("Id")).get( 193 | "Policy" 194 | ) 195 | remote_policy = p.get("PolicySummary") 196 | if local_policy.get("Name") != remote_policy.get( 197 | "Name" 198 | ) or local_policy.get("Description") != remote_policy.get("Description"): 199 | write_migration( 200 | EXTENSION, 201 | root_id, 202 | migrations.POLICY_DETAILS_UPDATE, 203 | dict( 204 | id=local_policy.get("Id"), 205 | name=local_policy.get("Name"), 206 | description=local_policy.get("Description"), 207 | ), 208 | ) 209 | local_policy_content = json.dumps( 210 | json.loads(open(policy_content_path, "r").read()) 211 | ) 212 | if local_policy_content != p.get("Content"): 213 | write_migration( 214 | EXTENSION, 215 | root_id, 216 | migrations.POLICY_CONTENT_UPDATE, 217 | dict(id=local_policy.get("Id"), content=local_policy_content), 218 | ) 219 | else: 220 | local_policy_content = json.dumps( 221 | json.loads(open(policy_content_path, "r").read()) 222 | ) 223 | write_migration( 224 | EXTENSION, 225 | root_id, 226 | migrations.POLICY_CREATE, 227 | dict( 228 | name=policy_file_path.split(SEP)[-2], content=local_policy_content 229 | ), 230 | ) 231 | 232 | 233 | def write_migration( 234 | extension: str, root_id: str, migration_type: str, migration_params: dict 235 | ) -> None: 236 | now = datetime.now() 237 | timestamp = datetime.timestamp(now) 238 | migration_file_name = f"{timestamp}_{migration_type}.yaml" 239 | os.makedirs(SEP.join(["environment", root_id, "_migrations"]), exist_ok=True) 240 | with open( 241 | SEP.join(["environment", root_id, "_migrations", migration_file_name]), "w" 242 | ) as f: 243 | f.write( 244 | yaml.safe_dump( 245 | dict( 246 | extension=extension, 247 | migration_type=migration_type, 248 | migration_params=migration_params, 249 | ) 250 | ) 251 | ) 252 | 253 | 254 | def check_attachment(root_id: str, policy_file_path: str, organizations) -> None: 255 | local_policies = yaml.safe_load(open(policy_file_path, "r").read()) 256 | meta = yaml.safe_load( 257 | open( 258 | policy_file_path.replace("_service_control_policies.yaml", "_meta.yaml"), 259 | "r", 260 | ).read() 261 | ) 262 | remote_policies = organizations.list_policies_for_target_single_page( 263 | TargetId=meta.get("Id"), Filter=SERVICE_CONTROL_POLICY 264 | ).get("Policies") 265 | for local_policy in local_policies.get("Attached", []): 266 | found = False 267 | for remote_policy in remote_policies: 268 | if local_policy.get("Name") == remote_policy.get("Name"): 269 | found = True 270 | if not found: 271 | write_migration( 272 | EXTENSION, 273 | root_id, 274 | migrations.POLICY_ATTACH, 275 | dict(policy_name=local_policy.get("Name"), target_id=meta.get("Id")), 276 | ) 277 | 278 | 279 | def check_attachments(root_id: str, organizations) -> None: 280 | policies = glob.glob( 281 | f"environment/{root_id}/**/_service_control_policies.yaml", recursive=True 282 | ) 283 | for policy_file_path in policies: 284 | check_attachment(root_id, policy_file_path, organizations) 285 | 286 | 287 | def make_migrations(role_arn: str, root_id: str) -> None: 288 | with betterboto_client.CrossAccountClientContextManager( 289 | "organizations", role_arn, f"organizations" 290 | ) as organizations: 291 | check_policies(root_id, organizations) 292 | check_attachments(root_id, organizations) 293 | 294 | 295 | def prune_metadata(root_id: str) -> None: 296 | policies = glob.glob( 297 | f"environment/{root_id}/_policies/**/_meta.yaml", recursive=True 298 | ) 299 | if len(policies) > 0: 300 | progress = bar.IncrementalBar( 301 | "Pruning SCP Policies metadata", max=len(policies) 302 | ) 303 | for policy in policies: 304 | progress.next() 305 | os.remove(policy) 306 | progress.finish() 307 | 308 | policies = glob.glob( 309 | f"environment/{root_id}/**/_service_control_policies.yaml", recursive=True 310 | ) 311 | if len(policies) > 0: 312 | progress = bar.IncrementalBar( 313 | "Pruning SCP Attachment metadata", max=len(policies) 314 | ) 315 | for policy in policies: 316 | progress.next() 317 | p = yaml.safe_load(open(policy, 'r').read()) 318 | new_attachment = dict(Attached=list(), Inherited=list()) 319 | for a in p.get("Attached", []): 320 | new_attachment["Attached"].append(dict(Name=a["Name"])) 321 | open(policy, 'w').write(yaml.safe_dump(new_attachment)) 322 | progress.finish() 323 | -------------------------------------------------------------------------------- /aws_organized/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import troposphere 4 | import yaml 5 | from awacs import ( 6 | organizations as awacs_organizations, 7 | aws, 8 | sts as awacs_sts, 9 | ssm as awscs_ssm, 10 | ) 11 | import pkg_resources 12 | from awacs.iam import ARN as IAM_ARN 13 | from troposphere import iam, s3, codebuild, codecommit, codepipeline, ssm 14 | from betterboto import client as betterboto_client 15 | 16 | 17 | def generate_role_template( 18 | command: str, 19 | actions: list, 20 | role_name: str, 21 | path: str, 22 | assuming_account_id: str, 23 | assuming_resource: str, 24 | additional_statements: list = [], 25 | ) -> troposphere.Template: 26 | t = troposphere.Template() 27 | t.description = f"Role used to run the {command} command" 28 | role = iam.Role( 29 | title="role", 30 | RoleName=role_name, 31 | Path=path, 32 | Policies=[ 33 | iam.Policy( 34 | PolicyName=f"{command}-permissions", 35 | PolicyDocument=aws.PolicyDocument( 36 | Version="2012-10-17", 37 | Id=f"{command}-permissions", 38 | Statement=[ 39 | aws.Statement( 40 | Sid="1", Effect=aws.Allow, Action=actions, Resource=["*"] 41 | ) 42 | ] 43 | + additional_statements, 44 | ), 45 | ) 46 | ], 47 | AssumeRolePolicyDocument=aws.Policy( 48 | Version="2012-10-17", 49 | Id="AllowAssume", 50 | Statement=[ 51 | aws.Statement( 52 | Sid="1", 53 | Effect=aws.Allow, 54 | Principal=aws.Principal( 55 | "AWS", [IAM_ARN(assuming_resource, "", assuming_account_id)] 56 | ), 57 | Action=[awacs_sts.AssumeRole], 58 | ) 59 | ], 60 | ), 61 | ) 62 | t.add_resource(role) 63 | t.add_output(troposphere.Output("RoleName", Value=troposphere.Ref(role))) 64 | t.add_output(troposphere.Output("RoleArn", Value=troposphere.GetAtt(role, "Arn"))) 65 | return t 66 | 67 | 68 | def generate_import_organization_role_template( 69 | role_name: str, path: str, assuming_account_id: str, assuming_resource: str 70 | ) -> troposphere.Template: 71 | return generate_role_template( 72 | "import-organizations", 73 | [ 74 | awacs_organizations.ListRoots, 75 | awacs_organizations.ListPoliciesForTarget, 76 | awacs_organizations.ListAccounts, 77 | awacs_organizations.ListChildren, 78 | awacs_organizations.DescribeOrganizationalUnit, 79 | awacs_organizations.ListParents, 80 | awacs_organizations.ListPolicies, 81 | awacs_organizations.DescribePolicy, 82 | awacs_organizations.ListTargetsForPolicy, 83 | awacs_organizations.ListDelegatedServicesForAccount, 84 | awacs_organizations.ListDelegatedAdministrators, 85 | ], 86 | role_name, 87 | path, 88 | assuming_account_id, 89 | assuming_resource, 90 | ) 91 | 92 | 93 | def provision_stack(stack_name_suffix: str, template: troposphere.Template) -> None: 94 | with betterboto_client.ClientContextManager("cloudformation") as cloudformation: 95 | cloudformation.create_or_update( 96 | StackName=f"AWSOrganized-{stack_name_suffix}", 97 | TemplateBody=template.to_yaml(clean_up=True), 98 | Capabilities=["CAPABILITY_NAMED_IAM"], 99 | ) 100 | 101 | 102 | def provision_import_organization_role_stack( 103 | role_name: str, path: str, assuming_account_id: str, assuming_resource: str 104 | ) -> troposphere.Template: 105 | template = generate_import_organization_role_template( 106 | role_name, path, assuming_account_id, assuming_resource 107 | ) 108 | provision_stack("import-organization-role", template) 109 | return template 110 | 111 | 112 | def generate_make_migrations_role_template( 113 | role_name: str, path: str, assuming_account_id: str, assuming_resource: str 114 | ) -> troposphere.Template: 115 | return generate_role_template( 116 | "make-migrations", 117 | [ 118 | awacs_organizations.DescribeOrganizationalUnit, 119 | awacs_organizations.ListParents, 120 | awacs_organizations.DescribePolicy, 121 | awacs_organizations.ListPoliciesForTarget, 122 | awacs_organizations.ListDelegatedServicesForAccount, 123 | awacs_organizations.ListDelegatedAdministrators, 124 | ], 125 | role_name, 126 | path, 127 | assuming_account_id, 128 | assuming_resource, 129 | ) 130 | 131 | 132 | def provision_make_migrations_role_stack( 133 | role_name: str, path: str, assuming_account_id: str, assuming_resource: str 134 | ) -> troposphere.Template: 135 | template = generate_make_migrations_role_template( 136 | role_name, path, assuming_account_id, assuming_resource 137 | ) 138 | provision_stack("make-migrations-role", template) 139 | return template 140 | 141 | 142 | def generate_migrate_role_template( 143 | role_name: str, 144 | path: str, 145 | assuming_account_id: str, 146 | assuming_resource: str, 147 | ssm_parameter_prefix: str, 148 | ) -> troposphere.Template: 149 | return generate_role_template( 150 | "migrate", 151 | [ 152 | awacs_organizations.CreateOrganizationalUnit, 153 | awacs_organizations.UpdateOrganizationalUnit, 154 | awacs_organizations.MoveAccount, 155 | awacs_organizations.CreatePolicy, 156 | awacs_organizations.UpdatePolicy, 157 | awacs_organizations.AttachPolicy, 158 | awacs_organizations.ListPolicies, 159 | awacs_organizations.RegisterDelegatedAdministrator, 160 | awacs_organizations.DeregisterDelegatedAdministrator, 161 | ], 162 | role_name, 163 | path, 164 | assuming_account_id, 165 | assuming_resource, 166 | [ 167 | aws.Statement( 168 | Sid="2", 169 | Effect=aws.Allow, 170 | Action=[ 171 | awscs_ssm.GetParameter, 172 | awscs_ssm.PutParameter, 173 | awscs_ssm.AddTagsToResource, 174 | ], 175 | Resource=[ 176 | troposphere.Sub( 177 | awscs_ssm.ARN( 178 | resource=f"parameter{ssm_parameter_prefix}/migrations/*", 179 | account="${AWS::AccountId}", 180 | region="${AWS::Region}", 181 | ) 182 | ) 183 | ], 184 | ) 185 | ], 186 | ) 187 | 188 | 189 | def provision_migrate_role_stack( 190 | role_name: str, 191 | path: str, 192 | assuming_account_id: str, 193 | assuming_resource: str, 194 | ssm_parameter_prefix: str, 195 | ) -> troposphere.Template: 196 | template = generate_migrate_role_template( 197 | role_name, path, assuming_account_id, assuming_resource, ssm_parameter_prefix 198 | ) 199 | provision_stack("migrate-role", template) 200 | return template 201 | 202 | 203 | def generate_codepipeline_template( 204 | codepipeline_role_name: str, 205 | codepipeline_role_path: str, 206 | codebuild_role_name: str, 207 | codebuild_role_path: str, 208 | ssm_parameter_prefix: str, 209 | scm_provider: str, 210 | scm_connection_arn: str, 211 | scm_full_repository_id: str, 212 | scm_branch_name: str, 213 | scm_bucket_name: str, 214 | scm_object_key: str, 215 | scm_skip_creation_of_repo: str, 216 | migrate_role_arn: str, 217 | ) -> troposphere.Template: 218 | version = pkg_resources.get_distribution("aws-organized").version 219 | t = troposphere.Template() 220 | t.set_description( 221 | "CICD template that runs aws organized migrate for the given branch of the given repo" 222 | ) 223 | project_name = "AWSOrganized-Migrate" 224 | bucket_name = scm_bucket_name 225 | if scm_provider.lower() == "codecommit" and scm_skip_creation_of_repo is False: 226 | t.add_resource( 227 | codecommit.Repository("Repository", RepositoryName=scm_full_repository_id) 228 | ) 229 | if scm_provider.lower() == "s3" and scm_skip_creation_of_repo is False: 230 | bucket_name = ( 231 | scm_bucket_name 232 | if scm_bucket_name 233 | else troposphere.Sub("aws-organized-pipeline-source-${AWS::AccountId}") 234 | ) 235 | t.add_resource( 236 | s3.Bucket( 237 | "Source", 238 | BucketName=bucket_name, 239 | VersioningConfiguration=s3.VersioningConfiguration(Status="Enabled"), 240 | BucketEncryption=s3.BucketEncryption( 241 | ServerSideEncryptionConfiguration=[ 242 | s3.ServerSideEncryptionRule( 243 | ServerSideEncryptionByDefault=s3.ServerSideEncryptionByDefault( 244 | SSEAlgorithm="AES256" 245 | ) 246 | ) 247 | ] 248 | ), 249 | ) 250 | ) 251 | artifact_store = t.add_resource( 252 | s3.Bucket( 253 | "ArtifactStore", 254 | VersioningConfiguration=s3.VersioningConfiguration(Status="Enabled"), 255 | BucketEncryption=s3.BucketEncryption( 256 | ServerSideEncryptionConfiguration=[ 257 | s3.ServerSideEncryptionRule( 258 | ServerSideEncryptionByDefault=s3.ServerSideEncryptionByDefault( 259 | SSEAlgorithm="AES256" 260 | ) 261 | ) 262 | ] 263 | ), 264 | ) 265 | ) 266 | codepipeline_role = t.add_resource( 267 | iam.Role( 268 | "CodePipelineRole", 269 | RoleName=codepipeline_role_name, 270 | Path=codepipeline_role_path, 271 | ManagedPolicyArns=["arn:aws:iam::aws:policy/AdministratorAccess"], 272 | AssumeRolePolicyDocument=aws.PolicyDocument( 273 | Version="2012-10-17", 274 | Statement=[ 275 | aws.Statement( 276 | Effect=aws.Allow, 277 | Action=[awacs_sts.AssumeRole], 278 | Principal=aws.Principal( 279 | "Service", ["codepipeline.amazonaws.com"] 280 | ), 281 | ) 282 | ], 283 | ), 284 | ) 285 | ) 286 | codebuild_role = t.add_resource( 287 | iam.Role( 288 | "CodeBuildRole", 289 | RoleName=codebuild_role_name, 290 | Path=codebuild_role_path, 291 | ManagedPolicyArns=["arn:aws:iam::aws:policy/AdministratorAccess"], 292 | AssumeRolePolicyDocument=aws.PolicyDocument( 293 | Version="2012-10-17", 294 | Statement=[ 295 | aws.Statement( 296 | Effect=aws.Allow, 297 | Action=[awacs_sts.AssumeRole], 298 | Principal=aws.Principal("Service", ["codebuild.amazonaws.com"]), 299 | ) 300 | ], 301 | ), 302 | ) 303 | ) 304 | version_parameter = ssm.Parameter( 305 | "versionparameter", 306 | Name=f"{ssm_parameter_prefix}/version", 307 | Type="String", 308 | Value=version, 309 | ) 310 | t.add_resource(version_parameter) 311 | project = t.add_resource( 312 | codebuild.Project( 313 | "AWSOrganizedMigrate", 314 | Artifacts=codebuild.Artifacts(Type="CODEPIPELINE"), 315 | Environment=codebuild.Environment( 316 | ComputeType="BUILD_GENERAL1_SMALL", 317 | Image="aws/codebuild/standard:4.0", 318 | Type="LINUX_CONTAINER", 319 | EnvironmentVariables=[ 320 | { 321 | "Name": "MIGRATE_ROLE_ARN", 322 | "Type": "PLAINTEXT", 323 | "Value": migrate_role_arn, 324 | }, 325 | { 326 | "Name": "Version", 327 | "Type": "PARAMETER_STORE", 328 | "Value": troposphere.Ref(version_parameter), 329 | }, 330 | { 331 | "Name": "SSM_PARAMETER_PREFIX", 332 | "Type": "PLAINTEXT", 333 | "Value": ssm_parameter_prefix, 334 | }, 335 | ], 336 | ), 337 | Name=project_name, 338 | ServiceRole=troposphere.GetAtt(codebuild_role, "Arn"), 339 | Source=codebuild.Source( 340 | Type="CODEPIPELINE", 341 | BuildSpec=yaml.safe_dump( 342 | dict( 343 | version="0.2", 344 | phases=dict( 345 | install={ 346 | "runtime-versions": dict(python="3.8"), 347 | "commands": ["pip install aws-organized==${Version}"], 348 | }, 349 | build={ 350 | "commands": [ 351 | "aws-organized migrate --ssm-parameter-prefix $SSM_PARAMETER_PREFIX $MIGRATE_ROLE_ARN" 352 | ] 353 | }, 354 | ), 355 | artifacts=dict(files=["environment"]), 356 | ) 357 | ), 358 | ), 359 | ) 360 | ) 361 | source_actions = dict( 362 | codecommit=codepipeline.Actions( 363 | Name="SourceAction", 364 | ActionTypeId=codepipeline.ActionTypeId( 365 | Category="Source", Owner="AWS", Version="1", Provider="CodeCommit" 366 | ), 367 | OutputArtifacts=[codepipeline.OutputArtifacts(Name="SourceOutput")], 368 | Configuration={ 369 | "RepositoryName": scm_full_repository_id, 370 | "BranchName": scm_branch_name, 371 | "PollForSourceChanges": "true", 372 | }, 373 | RunOrder="1", 374 | ), 375 | codestarsourceconnection=codepipeline.Actions( 376 | Name="SourceAction", 377 | ActionTypeId=codepipeline.ActionTypeId( 378 | Category="Source", 379 | Owner="AWS", 380 | Version="1", 381 | Provider="CodeStarSourceConnection", 382 | ), 383 | OutputArtifacts=[codepipeline.OutputArtifacts(Name="SourceOutput")], 384 | Configuration={ 385 | "ConnectionArn": scm_connection_arn, 386 | "FullRepositoryId": scm_full_repository_id, 387 | "BranchName": scm_branch_name, 388 | "OutputArtifactFormat": "CODE_ZIP", 389 | }, 390 | RunOrder="1", 391 | ), 392 | s3=codepipeline.Actions( 393 | Name="SourceAction", 394 | ActionTypeId=codepipeline.ActionTypeId( 395 | Category="Source", Owner="AWS", Version="1", Provider="S3" 396 | ), 397 | OutputArtifacts=[codepipeline.OutputArtifacts(Name="SourceOutput")], 398 | Configuration={ 399 | "S3Bucket": bucket_name, 400 | "S3ObjectKey": scm_object_key, 401 | "PollForSourceChanges": True, 402 | }, 403 | RunOrder="1", 404 | ), 405 | ).get(scm_provider.lower()) 406 | t.add_resource( 407 | codepipeline.Pipeline( 408 | "Pipeline", 409 | RoleArn=troposphere.GetAtt(codepipeline_role, "Arn"), 410 | Stages=[ 411 | codepipeline.Stages(Name="Source", Actions=[source_actions]), 412 | codepipeline.Stages( 413 | Name="Migrate", 414 | Actions=[ 415 | codepipeline.Actions( 416 | Name="Migrate", 417 | InputArtifacts=[ 418 | codepipeline.InputArtifacts(Name="SourceOutput") 419 | ], 420 | ActionTypeId=codepipeline.ActionTypeId( 421 | Category="Build", 422 | Owner="AWS", 423 | Version="1", 424 | Provider="CodeBuild", 425 | ), 426 | Configuration={ 427 | "ProjectName": troposphere.Ref(project), 428 | "PrimarySource": "SourceAction", 429 | }, 430 | RunOrder="1", 431 | ) 432 | ], 433 | ), 434 | ], 435 | ArtifactStore=codepipeline.ArtifactStore( 436 | Type="S3", Location=troposphere.Ref(artifact_store) 437 | ), 438 | ) 439 | ) 440 | return t 441 | 442 | 443 | def provision_codepipeline_stack( 444 | codepipeline_role_name: str, 445 | codepipeline_role_path: str, 446 | codebuild_role_name: str, 447 | codebuild_role_path: str, 448 | ssm_parameter_prefix: str, 449 | scm_provider: str, 450 | scm_connection_arn: str, 451 | scm_full_repository_id: str, 452 | scm_branch_name: str, 453 | scm_bucket_name: str, 454 | scm_object_key: str, 455 | scm_skip_creation_of_repo: str, 456 | migrate_role_arn: str, 457 | ) -> troposphere.Template: 458 | template = generate_codepipeline_template( 459 | codepipeline_role_name, 460 | codepipeline_role_path, 461 | codebuild_role_name, 462 | codebuild_role_path, 463 | ssm_parameter_prefix, 464 | scm_provider, 465 | scm_connection_arn, 466 | scm_full_repository_id, 467 | scm_branch_name, 468 | scm_bucket_name, 469 | scm_object_key, 470 | scm_skip_creation_of_repo, 471 | migrate_role_arn, 472 | ) 473 | provision_stack("codepipeline", template) 474 | return template 475 | -------------------------------------------------------------------------------- /aws_organized/migrations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | import sys 4 | from typing import Callable, Tuple 5 | import botocore 6 | 7 | OU_CREATE = "OU_CREATE" 8 | OU_CREATE_WITH_NON_EXISTENT_PARENT_OU = "OU_CREATE_WITH_NON_EXISTENT_PARENT_OU" 9 | OU_RENAME = "OU_RENAME" 10 | ACCOUNT_MOVE = "ACCOUNT_MOVE" 11 | ACCOUNT_MOVE_WITH_NON_EXISTENT_PARENT_OU = "ACCOUNT_MOVE_WITH_NON_EXISTENT_PARENT_OU" 12 | MigrationResult = Tuple[bool, str] 13 | OK = "Ok" 14 | 15 | 16 | def ou_create(root_id: str, client, name: str, parent_id: str) -> MigrationResult: 17 | try: 18 | client.create_organizational_unit(ParentId=parent_id, Name=name) 19 | except botocore.exceptions.ClientError as error: 20 | message = error.response["Error"]["Message"] 21 | return False, message 22 | except: 23 | message = sys.exc_info()[0] 24 | return False, "{0}".format(message) 25 | return True, OK 26 | 27 | 28 | def ou_create_with_non_existent_parent_ou( 29 | root_id: str, client, name: str, parent_ou_path: str 30 | ) -> MigrationResult: 31 | try: 32 | parent_id = client.convert_path_to_ou(parent_ou_path) 33 | return ou_create(client=client, name=name, parent_id=parent_id) 34 | except botocore.exceptions.ClientError as error: 35 | message = error.response["Error"]["Message"] 36 | return False, message 37 | except: 38 | message = sys.exc_info()[0] 39 | return False, "{0}".format(message) 40 | 41 | 42 | def ou_rename( 43 | root_id: str, client, name: str, organizational_unit_id: str 44 | ) -> MigrationResult: 45 | try: 46 | client.update_organizational_unit( 47 | OrganizationalUnitId=organizational_unit_id, Name=name 48 | ) 49 | return True, OK 50 | except botocore.exceptions.ClientError as error: 51 | message = error.response["Error"]["Message"] 52 | return False, message 53 | except: 54 | message = sys.exc_info()[0] 55 | return False, "{0}".format(message) 56 | 57 | 58 | def account_move( 59 | root_id: str, 60 | client, 61 | account_id: str, 62 | destination_parent_id: str, 63 | source_parent_id: str, 64 | ) -> MigrationResult: 65 | try: 66 | client.move_account( 67 | AccountId=account_id, 68 | SourceParentId=source_parent_id, 69 | DestinationParentId=destination_parent_id, 70 | ) 71 | return True, OK 72 | except botocore.exceptions.ClientError as error: 73 | message = error.response["Error"]["Message"] 74 | return False, message 75 | except: 76 | message = sys.exc_info()[0] 77 | return False, "{0}".format(message) 78 | 79 | 80 | def account_move_with_non_existent_parent_ou( 81 | root_id: str, client, account_id: str, source_parent_id: str, destination_path: str 82 | ) -> MigrationResult: 83 | try: 84 | destination_parent_id = client.convert_path_to_ou(destination_path) 85 | return account_move( 86 | client=client, 87 | account_id=account_id, 88 | destination_parent_id=destination_parent_id, 89 | source_parent_id=source_parent_id, 90 | ) 91 | except botocore.exceptions.ClientError as error: 92 | message = error.response["Error"]["Message"] 93 | return False, message 94 | except: 95 | message = sys.exc_info()[0] 96 | return False, "{0}".format(message) 97 | 98 | 99 | def get_function(migration_name) -> Callable[..., MigrationResult]: 100 | return { 101 | OU_CREATE: ou_create, 102 | OU_CREATE_WITH_NON_EXISTENT_PARENT_OU: ou_create_with_non_existent_parent_ou, 103 | OU_RENAME: ou_rename, 104 | ACCOUNT_MOVE: account_move, 105 | ACCOUNT_MOVE_WITH_NON_EXISTENT_PARENT_OU: account_move_with_non_existent_parent_ou, 106 | }.get(migration_name) 107 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "awacs" 3 | version = "2.0.2" 4 | description = "AWS Access Policy Language creation library" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6.2" 8 | 9 | [package.dependencies] 10 | typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} 11 | 12 | [[package]] 13 | name = "better-boto" 14 | version = "0.38.0" 15 | description = "Helpers to make using boto3 more enjoyable" 16 | category = "main" 17 | optional = false 18 | python-versions = "*" 19 | 20 | [package.dependencies] 21 | boto3 = "*" 22 | pyyaml = ">=4.2b1" 23 | 24 | [[package]] 25 | name = "boto3" 26 | version = "1.20.16" 27 | description = "The AWS SDK for Python" 28 | category = "main" 29 | optional = false 30 | python-versions = ">= 3.6" 31 | 32 | [package.dependencies] 33 | botocore = ">=1.23.16,<1.24.0" 34 | jmespath = ">=0.7.1,<1.0.0" 35 | s3transfer = ">=0.5.0,<0.6.0" 36 | 37 | [package.extras] 38 | crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] 39 | 40 | [[package]] 41 | name = "botocore" 42 | version = "1.23.16" 43 | description = "Low-level, data-driven core of boto 3." 44 | category = "main" 45 | optional = false 46 | python-versions = ">= 3.6" 47 | 48 | [package.dependencies] 49 | jmespath = ">=0.7.1,<1.0.0" 50 | python-dateutil = ">=2.1,<3.0.0" 51 | urllib3 = ">=1.25.4,<1.27" 52 | 53 | [package.extras] 54 | crt = ["awscrt (==0.12.5)"] 55 | 56 | [[package]] 57 | name = "cfn-flip" 58 | version = "1.3.0" 59 | description = "Convert AWS CloudFormation templates between JSON and YAML formats" 60 | category = "main" 61 | optional = false 62 | python-versions = "*" 63 | 64 | [package.dependencies] 65 | Click = "*" 66 | PyYAML = ">=4.1" 67 | six = "*" 68 | 69 | [[package]] 70 | name = "click" 71 | version = "7.1.2" 72 | description = "Composable command line interface toolkit" 73 | category = "main" 74 | optional = false 75 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 76 | 77 | [[package]] 78 | name = "jmespath" 79 | version = "0.10.0" 80 | description = "JSON Matching Expressions" 81 | category = "main" 82 | optional = false 83 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 84 | 85 | [[package]] 86 | name = "progress" 87 | version = "1.6" 88 | description = "Easy to use progress bars" 89 | category = "main" 90 | optional = false 91 | python-versions = "*" 92 | 93 | [[package]] 94 | name = "python-dateutil" 95 | version = "2.8.2" 96 | description = "Extensions to the standard Python datetime module" 97 | category = "main" 98 | optional = false 99 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 100 | 101 | [package.dependencies] 102 | six = ">=1.5" 103 | 104 | [[package]] 105 | name = "pyyaml" 106 | version = "5.4" 107 | description = "YAML parser and emitter for Python" 108 | category = "main" 109 | optional = false 110 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 111 | 112 | [[package]] 113 | name = "s3transfer" 114 | version = "0.5.0" 115 | description = "An Amazon S3 Transfer Manager" 116 | category = "main" 117 | optional = false 118 | python-versions = ">= 3.6" 119 | 120 | [package.dependencies] 121 | botocore = ">=1.12.36,<2.0a.0" 122 | 123 | [package.extras] 124 | crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] 125 | 126 | [[package]] 127 | name = "six" 128 | version = "1.16.0" 129 | description = "Python 2 and 3 compatibility utilities" 130 | category = "main" 131 | optional = false 132 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 133 | 134 | [[package]] 135 | name = "troposphere" 136 | version = "3.1.0" 137 | description = "AWS CloudFormation creation library" 138 | category = "main" 139 | optional = false 140 | python-versions = ">=3.6.2" 141 | 142 | [package.dependencies] 143 | cfn-flip = ">=1.0.2" 144 | typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} 145 | 146 | [package.extras] 147 | policy = ["awacs (>=2.0.0)"] 148 | 149 | [[package]] 150 | name = "typing-extensions" 151 | version = "4.0.0" 152 | description = "Backported and Experimental Type Hints for Python 3.6+" 153 | category = "main" 154 | optional = false 155 | python-versions = ">=3.6" 156 | 157 | [[package]] 158 | name = "urllib3" 159 | version = "1.26.7" 160 | description = "HTTP library with thread-safe connection pooling, file post, and more." 161 | category = "main" 162 | optional = false 163 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 164 | 165 | [package.extras] 166 | brotli = ["brotlipy (>=0.6.0)"] 167 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 168 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 169 | 170 | [metadata] 171 | lock-version = "1.1" 172 | python-versions = ">=3.7,<4" 173 | content-hash = "34e8c57a6939c20d64411edb16a94841a4f8b16218997f62e9e29f099c006a3c" 174 | 175 | [metadata.files] 176 | awacs = [ 177 | {file = "awacs-2.0.2-py3-none-any.whl", hash = "sha256:91b4e9039f6c84876b33fc8383e92e8cda4635792d238b70f14735a4ceb558fe"}, 178 | {file = "awacs-2.0.2.tar.gz", hash = "sha256:018138c10f82e11734aee7f9e7fff5dbfe1245ddaf15d5927f60f3b16e01ad7e"}, 179 | ] 180 | better-boto = [ 181 | {file = "better-boto-0.38.0.tar.gz", hash = "sha256:341b0c18db3dd110c2d7fbffb331fe4ea23f1160251c9f062ba9b89fbf7ea91b"}, 182 | {file = "better_boto-0.38.0-py3-none-any.whl", hash = "sha256:4c97b583d9a1d3b9dd0a9272b68df6dee1a7113e2a4ffc56ec71c8a15f2de724"}, 183 | ] 184 | boto3 = [ 185 | {file = "boto3-1.20.16-py3-none-any.whl", hash = "sha256:ef44676226c21215268be5c348c50fd9a2aed876de5c19962c79d9321f10f051"}, 186 | {file = "boto3-1.20.16.tar.gz", hash = "sha256:22808328fc81937244a971f739e5f8d95acd36c8e5638787e562d504b33727be"}, 187 | ] 188 | botocore = [ 189 | {file = "botocore-1.23.16-py3-none-any.whl", hash = "sha256:5656fd2b6aea9477d514d5ccf128d664e6ae4536ffca45a753844881dc65e0f8"}, 190 | {file = "botocore-1.23.16.tar.gz", hash = "sha256:c813e67c0f7d45cbff97a1047d8241f334eb386b3f81825e9e87e29d3a0c2ddf"}, 191 | ] 192 | cfn-flip = [ 193 | {file = "cfn_flip-1.3.0-py3-none-any.whl", hash = "sha256:faca8e77f0d32fb84cce1db1ef4c18b14a325d31125dae73c13bcc01947d2722"}, 194 | {file = "cfn_flip-1.3.0.tar.gz", hash = "sha256:003e02a089c35e1230ffd0e1bcfbbc4b12cc7d2deb2fcc6c4228ac9819307362"}, 195 | ] 196 | click = [ 197 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 198 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 199 | ] 200 | jmespath = [ 201 | {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, 202 | {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, 203 | ] 204 | progress = [ 205 | {file = "progress-1.6.tar.gz", hash = "sha256:c9c86e98b5c03fa1fe11e3b67c1feda4788b8d0fe7336c2ff7d5644ccfba34cd"}, 206 | ] 207 | python-dateutil = [ 208 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 209 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 210 | ] 211 | pyyaml = [ 212 | {file = "PyYAML-5.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:f7a21e3d99aa3095ef0553e7ceba36fb693998fbb1226f1392ce33681047465f"}, 213 | {file = "PyYAML-5.4-cp27-cp27m-win32.whl", hash = "sha256:52bf0930903818e600ae6c2901f748bc4869c0c406056f679ab9614e5d21a166"}, 214 | {file = "PyYAML-5.4-cp27-cp27m-win_amd64.whl", hash = "sha256:a36a48a51e5471513a5aea920cdad84cbd56d70a5057cca3499a637496ea379c"}, 215 | {file = "PyYAML-5.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5e7ac4e0e79a53451dc2814f6876c2fa6f71452de1498bbe29c0b54b69a986f4"}, 216 | {file = "PyYAML-5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc552b6434b90d9dbed6a4f13339625dc466fd82597119897e9489c953acbc22"}, 217 | {file = "PyYAML-5.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0dc9f2eb2e3c97640928dec63fd8dc1dd91e6b6ed236bd5ac00332b99b5c2ff9"}, 218 | {file = "PyYAML-5.4-cp36-cp36m-win32.whl", hash = "sha256:5a3f345acff76cad4aa9cb171ee76c590f37394186325d53d1aa25318b0d4a09"}, 219 | {file = "PyYAML-5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:f3790156c606299ff499ec44db422f66f05a7363b39eb9d5b064f17bd7d7c47b"}, 220 | {file = "PyYAML-5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:124fd7c7bc1e95b1eafc60825f2daf67c73ce7b33f1194731240d24b0d1bf628"}, 221 | {file = "PyYAML-5.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8b818b6c5a920cbe4203b5a6b14256f0e5244338244560da89b7b0f1313ea4b6"}, 222 | {file = "PyYAML-5.4-cp37-cp37m-win32.whl", hash = "sha256:737bd70e454a284d456aa1fa71a0b429dd527bcbf52c5c33f7c8eee81ac16b89"}, 223 | {file = "PyYAML-5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:7242790ab6c20316b8e7bb545be48d7ed36e26bbe279fd56f2c4a12510e60b4b"}, 224 | {file = "PyYAML-5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cc547d3ead3754712223abb7b403f0a184e4c3eae18c9bb7fd15adef1597cc4b"}, 225 | {file = "PyYAML-5.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8635d53223b1f561b081ff4adecb828fd484b8efffe542edcfdff471997f7c39"}, 226 | {file = "PyYAML-5.4-cp38-cp38-win32.whl", hash = "sha256:26fcb33776857f4072601502d93e1a619f166c9c00befb52826e7b774efaa9db"}, 227 | {file = "PyYAML-5.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2243dd033fd02c01212ad5c601dafb44fbb293065f430b0d3dbf03f3254d615"}, 228 | {file = "PyYAML-5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:31ba07c54ef4a897758563e3a0fcc60077698df10180abe4b8165d9895c00ebf"}, 229 | {file = "PyYAML-5.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:02c78d77281d8f8d07a255e57abdbf43b02257f59f50cc6b636937d68efa5dd0"}, 230 | {file = "PyYAML-5.4-cp39-cp39-win32.whl", hash = "sha256:fdc6b2cb4b19e431994f25a9160695cc59a4e861710cc6fc97161c5e845fc579"}, 231 | {file = "PyYAML-5.4-cp39-cp39-win_amd64.whl", hash = "sha256:8bf38641b4713d77da19e91f8b5296b832e4db87338d6aeffe422d42f1ca896d"}, 232 | {file = "PyYAML-5.4.tar.gz", hash = "sha256:3c49e39ac034fd64fd576d63bb4db53cda89b362768a67f07749d55f128ac18a"}, 233 | ] 234 | s3transfer = [ 235 | {file = "s3transfer-0.5.0-py3-none-any.whl", hash = "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"}, 236 | {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"}, 237 | ] 238 | six = [ 239 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 240 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 241 | ] 242 | troposphere = [ 243 | {file = "troposphere-3.1.0-py3-none-any.whl", hash = "sha256:ff9462897b7b59a4b7e7ea69d4572aa7afd8256e658daac6c4f0114c3c57e064"}, 244 | {file = "troposphere-3.1.0.tar.gz", hash = "sha256:45506ac737396d940c93eee68694a0f5cfeaba5c1fba2cd9d39335e713d0bacf"}, 245 | ] 246 | typing-extensions = [ 247 | {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, 248 | {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, 249 | ] 250 | urllib3 = [ 251 | {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, 252 | {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, 253 | ] 254 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | [tool.poetry] 5 | name = "aws-organized" 6 | version = "0.5.0" 7 | description = "Manage your AWS Organizations infrastructure using a simple file system based approach. You create files and folders that correspond to AWS Organizations organizational units and accounts and this tooling manages changes for you." 8 | classifiers = ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Natural Language :: English"] 9 | homepage = "https://github.com/aws-samples/aws-organized" 10 | repository = "https://github.com/aws-samples/aws-organized" 11 | readme = "README.md" 12 | 13 | authors = [ 14 | "Eamonn Faherty ", 15 | "Zulia Shavaeva " 16 | ] 17 | packages = [ 18 | { include = "aws_organized", from = "." }, 19 | ] 20 | include = ["aws_organized"] 21 | 22 | [tool.poetry.scripts] 23 | aws-organized = "aws_organized.cli:cli" 24 | 25 | [tool.poetry.dependencies] 26 | python = ">=3.7,<4" 27 | click = "^7.1.2" 28 | boto3 = "^1.16.4" 29 | better-boto = "0.38.0" 30 | PyYAML = "5.4" 31 | troposphere = "3.1.0" 32 | awacs = "2.0.2" 33 | progress = "^1.5" 34 | 35 | [tool.poetry.dev-dependencies] 36 | 37 | [build-system] 38 | requires = ["poetry-core>=1.0.0"] 39 | build-backend = "poetry.core.masonry.api" 40 | 41 | [tool.poetry.urls] 42 | issues = "https://github.com/aws-samples/aws-organized/issues" 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | package_dir = \ 5 | {'': '.'} 6 | 7 | packages = \ 8 | ['aws_organized', 9 | 'aws_organized.extensions', 10 | 'aws_organized.extensions.delegated_administrators', 11 | 'aws_organized.extensions.service_control_policies'] 12 | 13 | package_data = \ 14 | {'': ['*']} 15 | 16 | install_requires = \ 17 | ['PyYAML==5.4', 18 | 'awacs==2.0.2', 19 | 'better-boto==0.38.0', 20 | 'boto3>=1.16.4,<2.0.0', 21 | 'click>=7.1.2,<8.0.0', 22 | 'progress>=1.5,<2.0', 23 | 'troposphere==3.1.0'] 24 | 25 | entry_points = \ 26 | {'console_scripts': ['aws-organized = aws_organized.cli:cli']} 27 | 28 | setup_kwargs = { 29 | 'name': 'aws-organized', 30 | 'version': '0.5.0', 31 | 'description': 'Manage your AWS Organizations infrastructure using a simple file system based approach. You create files and folders that correspond to AWS Organizations organizational units and accounts and this tooling manages changes for you.', 32 | 'long_description': '# AWS Organized\n\n## What does this do?\n\nThis library does two things for you:\n\n1. It allows you to visualise and make changes to your AWS Organization using folders and files\n1. Instead of making changes directly you build up a migration which can then be reviewed before being applied.\n\n### How does it do this\n\nUsing a read only role with access to your AWS Organization you run an import-organization command. This generates a\ndirectory that represents your AWS Organization. It contains directories for OUs and accounts. It contains files\ndescribing the OUs, accounts and SCP policies.\n\nYou then make changes to the files and folders - for example, you move account folders to other OU folders to move the \naccount.\n\nOnce you are happy with your changes you run a make-migrations command. This generates some migrations files that\ndescribe what changes you are making. These changes should be reviewed and then added to git. You can then use your \nfave branching strategy to approve the change in your team. Once the changes are in your mainline they trigger a\npipeline that will run your migrations using a read/write role.\n\n## How can I use this?\n\n### Installing\n\nThis tool has been built in Python 3.7. We recommend using [pipx](https://github.com/pipxproject/pipx) to install this \ntool:\n\n```shell script\npipx install aws-organized\n```\n\n#### Setting up the IAM Roles\n\nThis tool ships with definitions for each IAM role with minimal permissions. \n\nYou can see and execute the commands as follows:\n\n##### Import Organization\nThe import organization command requires an IAM role in the Organizations management account. Before you provision the\nrole you need to decide where this tool will be installed. We recommend installing the tool into a dedicated prod \nshared services foundation account. Once you have that account which we will call the organized account you are ready\nto view or provision the template or stack. \n\n*You will need to provision this stack into your AWS Organizations management account*\n\nTo preview the template you can run:\n\n```shell script\naws-organized generate-import-organization-role-template \n```\n\nTo provision the stack you can run:\n\n```shell script\naws-organized provision-import-organization-role-stack \n```\n\n##### Make Migrations\nThe make-migrations command requires an IAM role in the Organizations management account.\n\n*You will need to provision this stack into your AWS Organizations management account*\n\nTo preview the template you can run:\n\n```shell script\naws-organized generate-make-migrations-role-template \n```\n\nTo provision the stack you can run:\n\n```shell script\naws-organized provision-make-migrations-role-stack \n```\n\n\n##### Migrate\nThe migrate command requires an IAM role in the Organizations management account.\n\n*You will need to provision this stack into your AWS Organizations management account*\n\nTo preview the template you can run:\n\n```shell script\naws-organized generate-migrate-role-template \n```\n\nTo provision the stack you can run:\n\n```shell script\naws-organized provision-migrate-role-stack \n```\n\n\n#### Setting up the pipelines\n\nWe recommend running the migrate command in a pipeline so that it is run in a controlled environment where history is \nrecorded and so audit is possible.\n\nYou can run this in AWS CodePipeline using our template.\n\nWhen running you have the option of which SCM you would like to use\n\n##### AWS CodeCommit\n\npreview the template:\n\n```shell script\naws-organized generate-codepipeline-template \n```\n\nprovision the template:\n\n```shell script\naws-organized provision-codepipeline-stack \n```\nPlease note, you can specify --scm-full-repository-id to provide the name of the repo and you can use scm-branch-name to provide a branch. If you omit either a default value will be used.\n\nFinally, you can specify --scm-skip-creation-of-repo and the template will not include the AWS CodeCommit repo - you can bring your own.\n\n##### AWS S3\n\npreview the template:\n\n```shell script\naws-organized generate-codepipeline-template --scm-provider s3 --scm-bucket-name foo --scm-object-key environment.zip \n```\n\nprovision the template:\n\n```shell script\naws-organized provision-codepipeline-stack --scm-provider s3 --scm-bucket-name foo --scm-object-key environment.zip \n```\n\nPlease note if you omit --scm-bucket-name we will auto generate a bucket name for you. If you omit --scm-object-key we will generate a value for you.\n\nFinally, you can specify --scm-skip-creation-of-repo and the template will not include the AWS S3 bucket - you can bring your own.\n\n##### Github / Github Enterprise / Bitbucket cloud (via CodeStarSourceConnections)\n\npreview the template:\n\n```shell script\naws-organized generate-codepipeline-template --scm-provider CodeStarSourceConnection --scm-connection-arn --scm-full-repository-id --scm-branch-name \n```\n\nprovision the template:\n\n```shell script\naws-organized provision-codepipeline-stack --scm-provider CodeStarSourceConnection --scm-connection-arn --scm-full-repository-id --scm-branch-name \n```\nIf you do not provide values for --scm-full-repository-id or --scm-branch-name default values will be provided for you.\n\n### Making changes to your Org\nBefore you can make changes you need to run:\n\n```shell script\naws-organized import-organization \n```\n\nwhere `import-organization-role` is the role created by the `provision-import-organization-role-stack` command\n\nOnce you run the import-organization command you have a directory created containing the accounts, OUs and SCPs defined:\n\n```shell script\nenvironment\n└── r-japk\n ├── _accounts\n │\xa0\xa0 └── eamonnf+SCT-demo-hub\n │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 └── _service_control_policies.yaml\n ├── _meta.yaml\n ├── _migrations\n │\xa0\xa0 ├── 1613407148.432513_POLICY_CONTENT_UPDATE.yaml\n │\xa0\xa0 └── 1613407148.435472_POLICY_CREATE.yaml\n ├── _organizational_units\n │\xa0\xa0 ├── foo\n │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 ├── _organizational_units\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── bar\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _organizational_units\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 └── sharedservices\n │\xa0\xa0 ├── _accounts\n │\xa0\xa0 │\xa0\xa0 ├── eamonnf+SCT-demo-spoke-1\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 │\xa0\xa0 ├── eamonnf+SCT-demo-spoke-2\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 │\xa0\xa0 ├── eamonnf+SCT-demo-spoke-4\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 │\xa0\xa0 └── eamonnf+SCT-demo-spoke-5\n │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 ├── _organizational_units\n │\xa0\xa0 │\xa0\xa0 └── infra\n │\xa0\xa0 │\xa0\xa0 ├── _accounts\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── eamonnf+SCT-demo-spoke-3\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 ├── _organizational_units\n │\xa0\xa0 │\xa0\xa0 └── _service_control_policies.yaml\n │\xa0\xa0 └── _service_control_policies.yaml\n ├── _policies\n │\xa0\xa0 └── service_control_policies\n │\xa0\xa0 ├── FullAWSAccess\n │\xa0\xa0 │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 │\xa0\xa0 └── policy.json\n │\xa0\xa0 ├── OnlyEc2\n │\xa0\xa0 │\xa0\xa0 └── policy.json\n │\xa0\xa0 └── OnlyS3\n │\xa0\xa0 ├── _meta.yaml\n │\xa0\xa0 └── policy.json\n └── _service_control_policies.yaml\n\n\n\n```\n\nYou can currently perform the following operations:\n\n#### Core features\nThe following capabilities are provided:\n\n##### Create an OU\nTo create an OU you need to create a directory within a new or existing _organizational_units directory. When creating\na directory you should not add the _meta.yaml file yourself. You should name the directory with the name of the OU\nyou want to use.\n\n##### Rename an OU\nTo rename an OU you need to rename the directory for the OU. You should not edit the attributes in the _meta.yaml file.\n\n##### Move an account\nTo move an account from one OU to another you have to move the directory for the account. You should move the contents\nof the directory with it - including the _meta.yaml and _service_control_policies.yaml files.\n\n#### Service Control Policy features\nThe following capabilities are provided:\n\n##### Create a policy\nTo create a policy you need to add a directory in the _policies/service_control_policies directory. The name of the \ndirectory becomes the initial name for the policy. Within the directory you need to add a file policy.json which \ncontains the actual SCP policy you want to attach. When you create a policy do not add a _meta.yaml file for it, the \ntool will add it for you. When you create a policy you cannot set the description, that needs to be another change.\n\n##### Update a policy\nTo update a policy you either modify the _meta.yaml file or the policy.json file. If you want to change the \ndescription change the attribute in your _meta.yaml file. If you want to change the policy content you will need to \nedit the policy.json. At the moment you cannot change the policy name.\n\n##### Attach a policy\nTo attach a policy to an OU or an account you should add it to the Attached section of the\n_service_control_policies.yaml file. Once you have added it, it should look like this:\n\n```yaml\nAttached:\n- Arn: arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess\n AwsManaged: true\n Description: Allows access to every operation\n Id: p-FullAWSAccess\n Name: FullAWSAccess\n Type: SERVICE_CONTROL_POLICY\n- Name: OnlyS3\nInherited:\n- Arn: arn:aws:organizations::aws:policy/service_control_policy/p-FullAWSAccess\n AwsManaged: true\n Description: Allows access to every operation\n Id: p-FullAWSAccess\n Name: FullAWSAccess\n Source: sharedservices\n Type: SERVICE_CONTROL_POLICY\n```\nIn the above example we appended the name only:\n\n```yaml\nName: OnlyS3\n```\n\nAWS-Organized will look up the rest of the details for you.\n\n### Generating migrations\nOnce you have made your changes you can then run `aws-organized make-migrations ` where\nmake-migrations-role-arn is the Arn of the role created in the steps above.\n\nThis creates a _migrations directory in your environment/organization direction. Within the _migrations directory\nthere should be a file describing the change you want to make.\n\n### Applying migrations\nOnce you have made your migrations you will want to review them - they are human (ish) readable YAML documents that\ndescribe the change you are applying. Once you are happy with them you will want to run them.\n\n#### Running migrations in a pipeline (recommended)\nOnce you have your migrations you add them to the git repository created in the create pipeline step above. The default\nname for the git repo is `AWS-Organized-environment`\n\n#### Running migrations locally (not recommended)\nOnce you have your migrations you can then run `aws-organized migrate ` where\nmigrate-role-arn is the Arn of the role created in the steps above.\n\n\n## Security\n\nSee [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.\n\n## License\n\nThis project is licensed under the Apache-2.0 License.\n\n#\n', 33 | 'author': 'Eamonn Faherty', 34 | 'author_email': 'eamonnf@amazon.co.uk', 35 | 'maintainer': None, 36 | 'maintainer_email': None, 37 | 'url': 'https://github.com/aws-samples/aws-organized', 38 | 'package_dir': package_dir, 39 | 'packages': packages, 40 | 'package_data': package_data, 41 | 'install_requires': install_requires, 42 | 'entry_points': entry_points, 43 | 'python_requires': '>=3.7,<4', 44 | } 45 | 46 | 47 | setup(**setup_kwargs) 48 | --------------------------------------------------------------------------------