├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEPLOYMENT.md ├── LICENSE ├── NOTICE ├── README.md ├── cloudformation ├── COCAstack.yml ├── COCAstack_leaf.yml ├── athena_transform.yml ├── delegated_centralization.yml └── linkedaccounts_collection.yml ├── img ├── coca-multi.png └── coca-standard.png └── scripts └── getCasesHistory.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.python-version 2 | *.tmp 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | ## Deployment Guide 2 | 3 | ### Table of content: 4 | 5 | * [Before you start](./DEPLOYMENT.md#before-you-start) 6 | 7 | * [Amazon QuickSight pre requisites](./DEPLOYMENT.md#amazon-quicksight-pre-requisites) 8 | 9 | * [Self hosting the CloudFormation stacks](./DEPLOYMENT.md#self-hosting-the-cloudformation-stacks) 10 | 11 | * [Standard deployment](./DEPLOYMENT.md#standard-deployment) 12 | 13 | * [Multi-Organizations deployment](./DEPLOYMENT.md#multi-organizations-deployment) 14 | 15 | * [Importing cases history](./DEPLOYMENT.md#importing-cases-history) 16 | 17 | 18 | ### Before you start 19 | 20 | * The solution's central dataset and dashboard reside in a linked account of your AWS Organizations (from now on referred to as Central Account). 21 | 22 | * The solution can be deployed for one or multiple AWS Organizations. The above-mentioned Central Account exists in the Central Organizations, where you will use the dashboard. The other Organizations will from now on be referred to as Leaf Organizations. 23 | 24 | * The solution can be deployed in any of the three regions where [AWS Support events are posted in the default eventBus](https://docs.aws.amazon.com/awssupport/latest/user/event-bridge-support.html): **us-east-1**, **us-west-2**, **eu-west-1**. 25 | 26 | * The solution uses [AWS CloudFormation stacksets](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/what-is-cfnstacksets.html). Enable Trusted Access to deploy CloudFormation stacksets across your AWS Organizations, which is documented in this [guide](https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-cloudformation.html). Grant self-managed permissions to your Payer and Central accounts as described in this [guide](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/stacksets-prereqs-self-managed.html). When going for the multi-Organizations deployment, you only need to grant self-managed permissions in your Central Organizations. 27 | 28 | * The solution requires an S3 bucket in the Central Account that will be used by Amazon Athena (from now on referred to as Athena Spillover bucket). It must be in the same region as the region you want to deploy the solution in. 29 | 30 | * The solution requires an active Amazon QuickSight subscription in the Central account. If you do not have one, follow the [documentation](https://docs.aws.amazon.com/quicksight/latest/user/signing-up.html) to sign up for QuickSight. An Enterprise subscription is required. 31 | 32 | ### Amazon QuickSight pre requisites 33 | 34 | All the actions listed below must be done in your Central Account. 35 | 36 | #### Give Amazon QuickSight read and write access to you Athena Spillover bucket 37 | 38 | 1. Inside Amazon QuickSight, choose your profile name (upper right). Choose **Manage QuickSight**, and then choose **Security & permissions**. 39 | 2. Choose **Add or remove**. 40 | 3. Locate Amazon S3 in the list. If the checkbox is clear, select the checkbox next to Amazon S3. If the checkbox is selected, choose **Details**, and then choose **Select S3 buckets**. 41 | 4. Choose your Athena Spillover bucket and select both check-boxes, the one in the **S3 bucket** column, and the one in the **Write permission for Athena Workgroup** column. 42 | 5. Choose **Finish**. 43 | 6. Choose **Save**. 44 | 45 | #### Create a group in Amazon QuickSight and add your user in the group 46 | 47 | 1. Inside Amazon QuickSight, choose your profile name (upper right). Choose **Manage QuickSight**, and then choose **Manage groups**. 48 | 2. Choose **New group**, and use **COCAAdmin** as name. 49 | 3. Choose **Create**. 50 | 4. Choose the **COCAAdmin** group. 51 | 5. Choose **Add user**. 52 | 6. Search your user and choose **Add**. 53 | 7. Repeat for any user you want to allow. If you can't find the user, make sure they have accessed Amazon QuickSight. 54 | 55 | #### Provision additional SPICE capacity 56 | 57 | Verify if you have enough free SPICE Capacity in the region you want to deploy in, or purchase extra capacity. 58 | 59 | 1. Inside Amazon QuickSight, choose your profile name (upper right), make sure you are in the region where you wan to deploy the solution. Choose **Manage QuickSight**, and then choose **SPICE capacity**. 60 | 2. Verify you have at least 10GB of free capacity. If you do, skip to the next pre requisite. 61 | 3. If you don't, Select **Purchase more capacity**. 62 | 4. Set 10GB and choose **Purchase SPICE capacity**. 63 | 64 | #### Allow Amazon QuickSight to invoke a Lambda function. 65 | 66 | 1. Open the Amazon IAM console and search the role you use for the Amazon QuickSight service. By default, it is **aws-quicksight-service-role-v0**. If you configured a different one, open it. 67 | 2. Choose **Add permissions** then **Create inline policy**. 68 | 3. Choose the **JSON** policy editor. 69 | 4. Add a policy such as the one below. Make sure you substitute `${accountid}` with the AWS Account ID of your Central Account. `${AthenaCatalogName}` must be substituted with the value that you will set for the CloudFormation stack parameter of the same name. It defaults to `ddbdata` in the stack. `${region}` must be substituted with the name of the region in which you attempt to deploy (one of **us-east-1**, **us-west-2** or **eu-west-1**). 70 | 71 | ``` 72 | { 73 | "Version": "2012-10-17", 74 | "Statement": [ 75 | { 76 | "Sid": "VisualEditor0", 77 | "Effect": "Allow", 78 | "Action": "lambda:InvokeFunction", 79 | "Resource": "arn:aws:lambda:${region}:${accountid}:function:${AthenaCatalogName}" 80 | } 81 | ] 82 | } 83 | ``` 84 | 5. Choose **Next**. 85 | 6. Give the inline policy a name, and choose **Create policy**. 86 | 87 | 88 | ### Self hosting the CloudFormation stacks 89 | 90 | 1. Create or reuse an existing S3 bucket. From now on we will refer to this bucket as **yourbucket**. 91 | 2. Create a bucket policy that allows your payer account(s) and the central account to retrieve the stacks. See example below, replace the AWS Account IDs with your account IDs, and `yourbucket` with the name of the bucket where you self host the CloudFormation stacks. 92 | 93 | ``` 94 | { 95 | "Version": "2012-10-17", 96 | "Statement": [ 97 | { 98 | "Effect": "Allow", 99 | "Principal": { 100 | "AWS": [ 101 | "arn:aws:iam::111111111111:root", 102 | "arn:aws:iam::222222222222:root", 103 | "arn:aws:iam::333333333333:root" 104 | ] 105 | }, 106 | "Action": [ 107 | "s3:GetObject", 108 | "s3:List*" 109 | ], 110 | "Resource": [ 111 | "arn:aws:s3:::yourbucket/*", 112 | "arn:aws:s3:::yourbucket" 113 | ] 114 | } 115 | ] 116 | } 117 | 118 | ``` 119 | 3. Clone this repository 120 | 4. Navigate in the directory where you cloned the repository, and then in the `cloudformation/` sub directory. 121 | 5. Upload the content of this sub directory at the root of yourbucket. 122 | 123 | ### Standard deployment 124 | 125 | 1. Open the [AWS Organizations console](https://us-east-1.console.aws.amazon.com/organizations/v2/home) in your payer account. Take note of the **Organizations ID** (o-xxxxxxxxxx) and the **Root ID** (r-yyyy). These will be used as parameters for the CloudFormation stack. 126 | 2. Open the [AWS CloudFormation console](https://us-east-1.console.aws.amazon.com/cloudformation/home) in the region you intend to deploy the solution in (as a reminder, it has to be one of **us-east-1**, **us-west-2** or **eu-west-1**). 127 | 3. Choose **Create stack**, then **With new resources (standard)**. 128 | 4. Choose **Template is ready**, **Amazon S3 URL**, and input the Amazon S3 URL of the self hosted COCAstack.yml in **yourbucket**, eg: https://yourbucket.s3.amazonaws.com/COCAstack.yml. 129 | 5. Choose **Next**. 130 | 6. Give the stack a name. 131 | 7. Leave all parameters to their default value, unless did not substitute **AthenaCatalogName** with **ddbdata** in the QuickSight pre requisites about allowing QuickSight to invoke a Lambda function. If that's the case, set **AthenaCatalogName** value to the same. 132 | 8. Set **AthenaSpilloverBucket** value to the name of your Athena Spillover bucket that you created in the Deployment guide pre requisites. 133 | 9. Set **CentralAccountId** value to the AWS Account ID of your Central Account. 134 | 10. Set **AWSOrgId** value to the ID of your AWS Organizations (noted in step #1). 135 | 11. Set **COCACollectionStackSetDeployTargetsOU** value to the ID of your Root OU (noted in step #1). 136 | 12. Set **COCABucket** value to the name of the bucket where you self host the stacks (eg: yourbucket). 137 | 13. If you signed up for Amazon QuickSight in a region that is not **us-east-1**, **COCAQuicksightRegion** to the region you signed up for Amazon QuickSight in. 138 | 14. Choose **Next**. 139 | 15. Add optional tags if you want. Leave **Permissions**, **Stack failure options**, and **Advanced options** as is. 140 | 16. Choose **Next**. 141 | 17. Review your stack options and parameters. Under **Capabilities**, select the checkbox, and then choose **Submit**. 142 | 143 | When the stack is done deploying, open the AWS Console for your Central Account. Open Amazon QuickSight, navigate to Dashboards, and you will see COCADashboard. To import your cases history, see [Importing cases history](./DEPLOYMENT.md#importing-cases-history) below. 144 | 145 | 146 | ### Multi-Organizations deployment 147 | 148 | In this deployment, start with each Leaf Organizations, then do the Central Organizations. 149 | 150 | The region you deploy in must be the same for all Organizations. 151 | 152 | #### In each Leaf Organizations 153 | 154 | 1. Open the [AWS Organizations console](https://us-east-1.console.aws.amazon.com/organizations/v2/home) in your payer account. Take note of the **Organizations ID** (o-xxxxxxxxxx) and the **Root ID** (r-yyyy). These will be used as parameters for the CloudFormation stack. 155 | 2. Open the [AWS CloudFormation console](https://us-east-1.console.aws.amazon.com/cloudformation/home) in the region you intend to deploy the solution in (as a reminder, it has to be one of **us-east-1**, **us-west-2** or **eu-west-1**). 156 | 3. Choose **Create stack**, then **With new resources (standard)**. 157 | 4. Choose **Template is ready**, **Amazon S3 URL**, and input the Amazon S3 URL of the self hosted COCAstack_leaf.yml in **yourbucket**, eg: https://yourbucket.s3.amazonaws.com/COCAstack_leaf.yml. 158 | 5. Choose **Next**. 159 | 6. Give the stack a name. 160 | 7. Set **CentralAccountId** value to the AWS Account ID of your Central Account. 161 | 8. Set **COCACollectionStackSetDeployTargetsOU** value to the ID of your Root OU (noted in step #1). 162 | 9. Set **COCABucket** value to the name of the bucket where you self host the stacks (eg: yourbucket). 163 | 10. Choose **Next**. 164 | 11. Add optional tags if you want. Leave **Permissions**, **Stack failure options**, and **Advanced options** as is. 165 | 12. Choose **Next**. 166 | 13. Review your stack options and parameters. Under **Capabilities**, select the checkbox, and then choose **Submit**. 167 | 168 | For each Leaf Organizations, take note of the **Organizations ID** and the **Payer Account ID**. They will be parameters for the CloudFormation stack in the Central Organizations. 169 | 170 | #### In the Central Organizations 171 | 172 | Only proceed with this step when the deployment is done in all your AWS Organizations, as there is a Lambda backed CustomResource that will try to use a role in every Leaf Organizations' payer account during deployment. It will fail if the role does not exist. 173 | 174 | 1. Open the [AWS Organizations console](https://us-east-1.console.aws.amazon.com/organizations/v2/home) in your payer account. Take note of the **Organizations ID** (o-xxxxxxxxxx) and the **Root ID** (r-yyyy). These will be used as parameters for the CloudFormation stack. 175 | 2. Open the [AWS CloudFormation console](https://us-east-1.console.aws.amazon.com/cloudformation/home) in the region you intend to deploy the solution in (as a reminder, it has to be one of **us-east-1**, **us-west-2** or **eu-west-1**). 176 | 3. Choose **Create stack**, then **With new resources (standard)**. 177 | 4. Choose **Template is ready**, **Amazon S3 URL**, and input the Amazon S3 URL of the self hosted COCAstack.yml in **yourbucket**, eg: https://yourbucket.s3.amazonaws.com/COCAstack.yml. 178 | 5. Choose **Next**. 179 | 6. Give the stack a name. 180 | 7. Leave all parameters to their default value, unless did not substitute **AthenaCatalogName** with **ddbdata** in the QuickSight pre requisites about allowing QuickSight to invoke a Lambda function. If that's the case, set **AthenaCatalogName** value to the same. 181 | 8. Set **AthenaSpilloverBucket** value to the name of your Athena Spillover bucket that you created in the Deployment guide pre requisites. 182 | 9. Set **CentralAccountId** value to the AWS Account ID of your Central Account. 183 | 10. Set **AWSOrgId** value to the IDs of all your AWS Organizations (Central and Leafs), as a comma separated string, eg: o-yyyyyyy,o-xxxxxxxx,o-zzzzzzzz 184 | 11. Set **COCACollectionStackSetDeployTargetsOU** value to the ID of your Root OU (noted in step #1). 185 | 12. Set **COCABucket** value to the name of the bucket where you self host the stacks (eg: yourbucket). 186 | 13. Set **COCAPayers** value to the Payer Account IDs of your Leaf AWS Organizations, as a comma separated string, eg: 111111111111,222222222222,333333333333 187 | 14. If you signed up for Amazon QuickSight in a region that is not **us-east-1**, **COCAQuicksightRegion** to the region you signed up for Amazon QuickSight in. 188 | 15. Choose **Next**. 189 | 16. Add optional tags if you want. Leave **Permissions**, **Stack failure options**, and **Advanced options** as is. 190 | 17. Choose **Next**. 191 | 18. Review your stack options and parameters. Under **Capabilities**, select the checkbox, and then choose **Submit**. 192 | 193 | When the stack is done deploying, open the AWS Console for your Central Account. Open Amazon QuickSight, navigate to Dashboards, and you will see COCADashboard. To import your cases history, see [Importing cases history](./DEPLOYMENT.md#importing-cases-history) below. 194 | 195 | ### Importing cases history 196 | 197 | For this step you will need valid CLI credentials for your Central Account. The easiest way is to use [AWS CloudShell](https://us-east-1.console.aws.amazon.com/cloudshell/home). 198 | 199 | 1. Clone a copy of this repository. 200 | 2. Navigate into the directory where you cloned the repository, and then the `scripts/` sub directory. 201 | 3. Execute the script as described below, depending on which deployment you opted for. 202 | 203 | ``` 204 | ❯ python getCasesHistory.py -h 205 | usage: getCasesHistory.py [-h] [-d n] [-p] [-r] [-o] 206 | -h: this help 207 | -d/--days n: import cases created in the last days 208 | -p/--payers: comma separated list of payers 209 | -r/--assume-role: name of the role to assume in each account for DescribeCases calls (defaults to COCAAssumeRole) 210 | -o/--account-map-role: name of the role to assume in the payer account(s) to describe the Organizations (defaults to COCAAccountMapRole) 211 | ``` 212 | 213 | For a standard deployment, the following will import cases created in the last 120 days. 214 | ``` 215 | ❯ python getCasesHistory.py 216 | ``` 217 | Use `-d` to control the look-back period. 218 | ``` 219 | ❯ python getCasesHistory.py -d 360 220 | ``` 221 | 222 | For a multi-Organizations deployment, you have to give the list of payer accounts for each Leaf Organizations. 223 | ``` 224 | ❯ python getCasesHistory.py -d 360 -p "111111111111,222222222222,333333333333" 225 | ``` 226 | 227 | 4. Once the script is done executing, open Amazon QuickSight in your Central Account. Navigate to Datasets, choose **COCAQuicksightDataset**. 228 | 5. Open the **Refresh** tab, and choose **Refresh now**. 229 | 230 | When the dataset is done refreshing, open the dashboard. 231 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Consolidated Organizations-wide Cases Activity 2 | 3 | Consolidated Organizations-wide Case Activity (COCA) is a solution consisting in a data collection framework and a dashboard that allows customers to centrally monitor and extract insights from their case activity. The dashboard is hosted in QuickSight and the whole solution is made available as a set of CloudFormation templates. The dashboard provides visualisations for customer to quickly view cases by status, severity, AWS account, AWS service and such as top service code and top movers. Users of the solution are able to view the support cases for all accounts in their AWS Organization(s) in a single dashboard. 4 | 5 | ### Key features 6 | 7 | * Centralized dataset and dashboard: This solution builds a central dataset of all cases activity for all AWS accounts. A dashboard is available to visualize the content of the dataset. 8 | 9 | * Support for multiple Organizations: This solution can be used for multiple AWS Organizations. The dashboard provide controls to filter based on parent Organization, or Organizational Units. 10 | 11 | * Ease of deployment: The whole solution can be deployed and updated with a CloudFormation stack. No need to run `awscli` commands or python scripts for deployment. 12 | 13 | * Customizable import of historical data: This solution ships with a post deployment script allowing the import of historical data with a customizable lookback period (defaults to 120 days). 14 | 15 | ### Building blocks 16 | 17 | This solution is built with AWS Support API, AWS Organizations API, Amazon EventBridge, AWS Lambda, Amazon Athena, Amazon QuickSight, Amazon DynamoDB. 18 | 19 | ## Reference Architectures 20 | 21 | ### Standard deployment 22 | 23 | ![ALT](img/coca-standard.png) 24 | 25 | ### Multi-Org deployment 26 | 27 | ![ALT](img/coca-multi.png) 28 | 29 | ### Deploying the solution 30 | 31 | Find a step by step walkthrough in our [Deployment guide](./DEPLOYMENT.md). 32 | 33 | ## License 34 | 35 | This project is licensed under the [Apache-2.0 License](./LICENSE). 36 | -------------------------------------------------------------------------------- /cloudformation/COCAstack.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Metadata: 3 | AWS::CloudFormation::Interface: 4 | ParameterGroups: 5 | - Label: 6 | default: Identity and Access Management 7 | Parameters: 8 | - COCACreateIAMRoles 9 | - COCAAssumeRoleName 10 | - COCAAccountMapRoleName 11 | - COCASeederRoleName 12 | - COCAAccountMapLambdaRoleName 13 | - COCAEventBridgeForwardRuleRoleName 14 | - COCAEventBridgeInvokeRuleRoleName 15 | - Label: 16 | default: QuickSight & Athena 17 | Parameters: 18 | - COCAQuicksightRegion 19 | - AthenaCatalogName 20 | - AthenaSpilloverBucket 21 | - Label: 22 | default: Data collection stackset 23 | Parameters: 24 | - CentralAccountId 25 | - CentralAccountEventBusName 26 | - COCAPayers 27 | - AWSOrgId 28 | - COCACollectionStackSetDeployTargetsOU 29 | 30 | Parameters: 31 | COCABucket: 32 | Type: String 33 | Description: S3 bucket were stacks and stackset templates are available 34 | COCACollectionStackSetDeployTargetsOU: 35 | Type: String 36 | Description: AWS Orgnizations OU for stackset deployment 37 | AWSOrgId: 38 | Type: String 39 | Description: AWS Organizations ID. For multi org setup, provide a comma separated list of org ID 40 | AthenaCatalogName: 41 | Type: String 42 | Description: Name of the athena data catalog 43 | Default: "ddbdata" 44 | AthenaSpilloverBucket: 45 | Type: String 46 | Description: Bucket for the athena spillover 47 | COCACreateIAMRoles: 48 | Type: String 49 | AllowedValues: ["yes", "no"] 50 | Default: "yes" 51 | Description: Set to no if you provision the IAM roles separately 52 | COCAAssumeRoleName: 53 | Type: String 54 | Default: "COCAAssumeRole" 55 | Description: Name of the IAM role that the Lambda function will assume to query AWS Support API 56 | COCAQuicksightRegion: 57 | Type: String 58 | Default: "us-east-1" 59 | Description: AWS Region where you manage QuickSight users ang groups 60 | CentralAccountId: 61 | Type: String 62 | Description: AWS Account where you centralize support cases data 63 | CentralAccountEventBusName: 64 | Type: String 65 | Default: "COCABus" 66 | Description: Name of the dedicated EventBus for data collection 67 | COCAAccountMapRoleName: 68 | Type: String 69 | Default: "COCAAccountMapRole" 70 | COCAEventBridgeInvokeRuleRoleName: 71 | Type: String 72 | Default: "COCAInvokeRuleRole" 73 | COCAEventBridgeForwardRuleRoleName: 74 | Type: String 75 | Default: "COCAForwardRuleRole" 76 | COCAAccountMapLambdaRoleName: 77 | Type: String 78 | Default: "COCAAccountMapLambdaRole" 79 | COCASeederRoleName: 80 | Type: String 81 | Default: "COCASeederRole" 82 | COCAPayers: 83 | Type: String 84 | Description: Leave the default value if you are not doing a multi-Orgnizations deployment. If you are, provide the list of payer accounts for your Leaf Organizations, as a comma separated string. 85 | Default: "none" 86 | 87 | Conditions: 88 | CreateIAMRole: !Equals 89 | - !Ref COCACreateIAMRoles 90 | - "yes" 91 | IsStandardDeployment: !Equals 92 | - !Ref COCAPayers 93 | - "none" 94 | 95 | 96 | Resources: 97 | COCAAssumeRolePolicy: 98 | Type: 'AWS::IAM::Policy' 99 | Condition: CreateIAMRole 100 | Metadata: 101 | cfn_nag: 102 | rules_to_suppress: 103 | - id: W12 104 | reason: Policy contains actions(s) that only support * resource 105 | Properties: 106 | PolicyDocument: 107 | Statement: 108 | - Effect: Allow 109 | Action: 110 | - 'support:DescribeCases' 111 | Resource: '*' 112 | Version: 2012-10-17 113 | PolicyName: COCAAssumeRolePolicy-WildcardPermissions 114 | Roles: 115 | - Ref: COCAAssumeRole 116 | 117 | COCAAssumeRole: 118 | Type: 'AWS::IAM::Role' 119 | Metadata: 120 | cfn_nag: 121 | rules_to_suppress: 122 | - id: W28 123 | reason: IAM role requires explicit name 124 | Condition: CreateIAMRole 125 | Properties: 126 | AssumeRolePolicyDocument: 127 | Version: "2012-10-17" 128 | Statement: 129 | - Effect: Allow 130 | Principal: 131 | AWS: !Sub "arn:${AWS::Partition}:iam::${CentralAccountId}:root" 132 | Action: 133 | - 'sts:AssumeRole' 134 | RoleName: !Sub "${COCAAssumeRoleName}" 135 | 136 | COCAAccountMapRolePolicy: 137 | Type: 'AWS::IAM::Policy' 138 | Condition: CreateIAMRole 139 | Metadata: 140 | cfn_nag: 141 | rules_to_suppress: 142 | - id: W12 143 | reason: Policy contains actions(s) that only support * resource 144 | Properties: 145 | PolicyDocument: 146 | Statement: 147 | - Effect: Allow 148 | Action: 149 | - 'organizations:ListAccounts' 150 | - 'organizations:DescribeOrganization' 151 | - 'organizations:DescribeOrganizationalUnit' 152 | - 'organizations:ListChildren' 153 | - 'organizations:ListAccountsForParent' 154 | - 'organizations:ListRoots' 155 | - 'organizations:ListOrganizationalUnitsForParent' 156 | - 'organizations:DescribeAccount' 157 | Resource: '*' 158 | Version: 2012-10-17 159 | PolicyName: COCAAccountMapRolePolicy-WildcardPermissions 160 | Roles: 161 | - Ref: COCAAccountMapRole 162 | 163 | COCAAccountMapRole: 164 | Type: 'AWS::IAM::Role' 165 | Metadata: 166 | cfn_nag: 167 | rules_to_suppress: 168 | - id: W28 169 | reason: IAM role requires explicit name 170 | Condition: CreateIAMRole 171 | Properties: 172 | AssumeRolePolicyDocument: 173 | Version: "2012-10-17" 174 | Statement: 175 | - Effect: Allow 176 | Principal: 177 | AWS: !Sub "arn:${AWS::Partition}:iam::${CentralAccountId}:root" 178 | Action: 179 | - 'sts:AssumeRole' 180 | RoleName: !Sub "${COCAAccountMapRoleName}" 181 | 182 | COCAEventBridgeInvokeEventBusRole: 183 | Type: 'AWS::IAM::Role' 184 | Metadata: 185 | cfn_nag: 186 | rules_to_suppress: 187 | - id: W28 188 | reason: IAM role requires explicit name 189 | Condition: CreateIAMRole 190 | Properties: 191 | RoleName: !Sub "${COCAEventBridgeForwardRuleRoleName}" 192 | AssumeRolePolicyDocument: 193 | Version: "2012-10-17" 194 | Statement: 195 | - Effect: Allow 196 | Principal: 197 | Service: 198 | - events.amazonaws.com 199 | Action: 200 | - 'sts:AssumeRole' 201 | Policies: 202 | - PolicyName: COCAEventBridgeInvokeEventBusRolePolicy 203 | PolicyDocument: 204 | Version: "2012-10-17" 205 | Statement: 206 | - Effect: Allow 207 | Action: 208 | - 'events:PutEvents' 209 | Resource: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 210 | 211 | COCAEventBridgeForwardingRule: 212 | Type: 'AWS::Events::Rule' 213 | Properties: 214 | State: ENABLED 215 | EventPattern: 216 | source: 217 | - 'aws.support' 218 | detail-type: 219 | - 'Support Case Update' 220 | Targets: 221 | - Arn: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 222 | Id: !Sub ${CentralAccountEventBusName} 223 | RoleArn: 224 | !If 225 | - CreateIAMRole 226 | - !GetAtt 227 | - COCAEventBridgeInvokeEventBusRole 228 | - Arn 229 | - !Sub arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}:role/${COCAEventBridgeForwardRuleRoleName} 230 | 231 | 232 | COCACollectionStackSet: 233 | Type: 'AWS::CloudFormation::StackSet' 234 | DependsOn: COCACentralAccountStackSet 235 | Properties: 236 | StackSetName: COCAKPICollectionStackSet 237 | PermissionModel: SERVICE_MANAGED 238 | TemplateURL: !Sub "https://${COCABucket}.s3.amazonaws.com/linkedaccounts_collection.yml" 239 | AutoDeployment: 240 | Enabled: true 241 | RetainStacksOnAccountRemoval: false 242 | CallAs: SELF 243 | Capabilities: 244 | - CAPABILITY_NAMED_IAM 245 | StackInstancesGroup: 246 | - DeploymentTargets: 247 | AccountFilterType: DIFFERENCE 248 | Accounts: 249 | - !Ref CentralAccountId 250 | OrganizationalUnitIds: 251 | - !Ref COCACollectionStackSetDeployTargetsOU 252 | Regions: 253 | - !Sub "${AWS::Region}" 254 | OperationPreferences: { 255 | "FailureTolerancePercentage": 100, 256 | "MaxConcurrentPercentage": 100 257 | } 258 | Parameters: 259 | - 260 | ParameterKey: CentralAccountId 261 | ParameterValue: !Ref CentralAccountId 262 | - 263 | ParameterKey: CentralAccountEventBusName 264 | ParameterValue: !Ref CentralAccountEventBusName 265 | - 266 | ParameterKey: COCAAssumeRoleName 267 | ParameterValue: !Ref COCAAssumeRoleName 268 | - 269 | ParameterKey: COCACreateIAMRoles 270 | ParameterValue: !Ref COCACreateIAMRoles 271 | - 272 | ParameterKey: COCAEventBridgeForwardRuleRoleName 273 | ParameterValue: !Ref COCAEventBridgeForwardRuleRoleName 274 | 275 | 276 | COCACentralAccountStackSet: 277 | Type: 'AWS::CloudFormation::StackSet' 278 | Properties: 279 | StackSetName: COCACentralAccountStackSet 280 | PermissionModel: SELF_MANAGED 281 | TemplateURL: !Sub "https://${COCABucket}.s3.amazonaws.com/delegated_centralization.yml" 282 | CallAs: SELF 283 | Capabilities: 284 | - CAPABILITY_NAMED_IAM 285 | - CAPABILITY_AUTO_EXPAND 286 | StackInstancesGroup: 287 | - DeploymentTargets: 288 | Accounts: 289 | - !Ref CentralAccountId 290 | Regions: 291 | - !Sub "${AWS::Region}" 292 | OperationPreferences: { 293 | "FailureTolerancePercentage": 100, 294 | "MaxConcurrentPercentage": 100 295 | } 296 | Parameters: 297 | - 298 | ParameterKey: AWSOrgId 299 | ParameterValue: !Ref AWSOrgId 300 | - 301 | ParameterKey: AthenaCatalogName 302 | ParameterValue: !Ref AthenaCatalogName 303 | - 304 | ParameterKey: AthenaSpilloverBucket 305 | ParameterValue: !Ref AthenaSpilloverBucket 306 | - 307 | ParameterKey: COCAAssumeRoleName 308 | ParameterValue: !Ref COCAAssumeRoleName 309 | - 310 | ParameterKey: COCACreateIAMRoles 311 | ParameterValue: !Ref COCACreateIAMRoles 312 | - 313 | ParameterKey: COCAEventBridgeInvokeRuleRoleName 314 | ParameterValue: !Ref COCAEventBridgeInvokeRuleRoleName 315 | - 316 | ParameterKey: COCAEventBridgeForwardRuleRoleName 317 | ParameterValue: !Ref COCAEventBridgeForwardRuleRoleName 318 | - 319 | ParameterKey: COCAAccountMapRoleName 320 | ParameterValue: !Ref COCAAccountMapRoleName 321 | - 322 | ParameterKey: COCAAccountMapLambdaRoleName 323 | ParameterValue: !Ref COCAAccountMapLambdaRoleName 324 | - 325 | ParameterKey: COCASeederRoleName 326 | ParameterValue: !Ref COCASeederRoleName 327 | - 328 | ParameterKey: COCAQuicksightRegion 329 | ParameterValue: !Ref COCAQuicksightRegion 330 | - 331 | ParameterKey: COCABucket 332 | ParameterValue: !Ref COCABucket 333 | - 334 | ParameterKey: COCAPayers 335 | ParameterValue: !If 336 | - IsStandardDeployment 337 | - !Sub "${AWS::AccountId}" 338 | - !Sub "${AWS::AccountId},${COCAPayers}" 339 | 340 | -------------------------------------------------------------------------------- /cloudformation/COCAstack_leaf.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | COCABucket: 4 | Type: String 5 | Description: S3 bucket were lambda.zip and stackset template are available 6 | COCACollectionStackSetDeployTargetsOU: 7 | Type: String 8 | Description: AWS Orgnizations OU for stackset deployment. 9 | COCAAssumeRoleName: 10 | Type: String 11 | Default: "COCAAssumeRole" 12 | CentralAccountId: 13 | Type: String 14 | CentralAccountEventBusName: 15 | Type: String 16 | Default: "COCABus" 17 | COCAAccountMapRoleName: 18 | Type: String 19 | Default: "COCAAccountMapRole" 20 | COCAEventBridgeForwardRuleRoleName: 21 | Type: String 22 | Default: "COCAForwardRuleRole" 23 | 24 | Resources: 25 | COCAAssumeRolePolicy: 26 | Type: 'AWS::IAM::Policy' 27 | Metadata: 28 | cfn_nag: 29 | rules_to_suppress: 30 | - id: W12 31 | reason: Policy contains actions(s) that only support * resource 32 | Properties: 33 | PolicyDocument: 34 | Statement: 35 | - Effect: Allow 36 | Action: 37 | - 'support:DescribeCases' 38 | Resource: '*' 39 | Version: 2012-10-17 40 | PolicyName: COCAAssumeRolePolicy-WildcardPermissions 41 | Roles: 42 | - Ref: COCAAssumeRole 43 | 44 | COCAAssumeRole: 45 | Type: 'AWS::IAM::Role' 46 | Metadata: 47 | cfn_nag: 48 | rules_to_suppress: 49 | - id: W28 50 | reason: IAM role requires explicit name 51 | Properties: 52 | AssumeRolePolicyDocument: 53 | Version: "2012-10-17" 54 | Statement: 55 | - Effect: Allow 56 | Principal: 57 | AWS: !Sub "arn:${AWS::Partition}:iam::${CentralAccountId}:root" 58 | Action: 59 | - 'sts:AssumeRole' 60 | RoleName: !Sub "${COCAAssumeRoleName}" 61 | 62 | COCAAccountMapRolePolicy: 63 | Type: 'AWS::IAM::Policy' 64 | Metadata: 65 | cfn_nag: 66 | rules_to_suppress: 67 | - id: W12 68 | reason: Policy contains actions(s) that only support * resource 69 | Properties: 70 | PolicyDocument: 71 | Statement: 72 | - Effect: Allow 73 | Action: 74 | - 'organizations:ListAccounts' 75 | - 'organizations:DescribeOrganization' 76 | - 'organizations:DescribeOrganizationalUnit' 77 | - 'organizations:ListChildren' 78 | - 'organizations:ListAccountsForParent' 79 | - 'organizations:ListRoots' 80 | - 'organizations:ListOrganizationalUnitsForParent' 81 | - 'organizations:DescribeAccount' 82 | Resource: '*' 83 | Version: 2012-10-17 84 | PolicyName: COCAAccountMapRolePolicy-WildcardPermissions 85 | Roles: 86 | - Ref: COCAAccountMapRole 87 | 88 | COCAAccountMapRole: 89 | Type: 'AWS::IAM::Role' 90 | Metadata: 91 | cfn_nag: 92 | rules_to_suppress: 93 | - id: W28 94 | reason: IAM role requires explicit name 95 | Properties: 96 | AssumeRolePolicyDocument: 97 | Version: "2012-10-17" 98 | Statement: 99 | - Effect: Allow 100 | Principal: 101 | AWS: !Sub "arn:${AWS::Partition}:iam::${CentralAccountId}:root" 102 | Action: 103 | - 'sts:AssumeRole' 104 | RoleName: !Sub "${COCAAccountMapRoleName}" 105 | 106 | COCAEventBridgeInvokeEventBusRole: 107 | Type: 'AWS::IAM::Role' 108 | Properties: 109 | AssumeRolePolicyDocument: 110 | Version: "2012-10-17" 111 | Statement: 112 | - Effect: Allow 113 | Principal: 114 | Service: 115 | - events.amazonaws.com 116 | Action: 117 | - 'sts:AssumeRole' 118 | Policies: 119 | - PolicyName: COCAEventBridgeInvokeEventBusRolePolicy 120 | PolicyDocument: 121 | Version: "2012-10-17" 122 | Statement: 123 | - Effect: Allow 124 | Action: 125 | - 'events:PutEvents' 126 | Resource: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 127 | 128 | COCAEventBridgeForwardingRule: 129 | Type: 'AWS::Events::Rule' 130 | Properties: 131 | State: ENABLED 132 | EventPattern: 133 | source: 134 | - 'aws.support' 135 | detail-type: 136 | - 'Support Case Update' 137 | Targets: 138 | - Arn: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 139 | Id: !Sub ${CentralAccountEventBusName} 140 | RoleArn: !GetAtt 141 | - COCAEventBridgeInvokeEventBusRole 142 | - Arn 143 | 144 | COCACollectionStackSet: 145 | Type: 'AWS::CloudFormation::StackSet' 146 | Properties: 147 | StackSetName: COCAKPICollectionStackSet 148 | PermissionModel: SERVICE_MANAGED 149 | TemplateURL: !Sub "https://${COCABucket}.s3.amazonaws.com/linkedaccounts_collection.yml" 150 | AutoDeployment: 151 | Enabled: true 152 | RetainStacksOnAccountRemoval: false 153 | CallAs: SELF 154 | Capabilities: 155 | - CAPABILITY_NAMED_IAM 156 | StackInstancesGroup: 157 | - DeploymentTargets: 158 | OrganizationalUnitIds: 159 | - !Ref COCACollectionStackSetDeployTargetsOU 160 | Regions: 161 | - !Sub "${AWS::Region}" 162 | OperationPreferences: { 163 | "FailureTolerancePercentage": 100, 164 | "MaxConcurrentPercentage": 100 165 | } 166 | Parameters: 167 | - 168 | ParameterKey: CentralAccountId 169 | ParameterValue: !Ref CentralAccountId 170 | - 171 | ParameterKey: CentralAccountEventBusName 172 | ParameterValue: !Ref CentralAccountEventBusName 173 | - 174 | ParameterKey: COCAAssumeRoleName 175 | ParameterValue: !Ref COCAAssumeRoleName 176 | - 177 | ParameterKey: COCACreateIAMRoles 178 | ParameterValue: "yes" 179 | - 180 | ParameterKey: COCAEventBridgeForwardRuleRoleName 181 | ParameterValue: !Ref COCAEventBridgeForwardRuleRoleName 182 | -------------------------------------------------------------------------------- /cloudformation/athena_transform.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | AthenaSpilloverBucket: 4 | Type: String 5 | Description: The bucket where we can spill data. 6 | AthenaSpilloverPrefix: 7 | Type: String 8 | Description: The prefix within SpillBucket where we can spill data. 9 | Default: athena-spill 10 | AthenaCatalogName: 11 | Type: String 12 | Description: The name you will give to this catalog in Athena. It will also be 13 | used as the function name. This name must satisfy the pattern ^[a-z0-9-_]{1,64}$ 14 | AllowedPattern: ^[a-z0-9-_]{1,64}$ 15 | AthenaLambdaTimeout: 16 | Type: Number 17 | Default: 900 18 | Description: Maximum Lambda invocation runtime in seconds. (min 1 - 900 max) 19 | AthenaLambdaMemory: 20 | Type: Number 21 | Default: 3008 22 | Description: Lambda memory in MB (min 128 - 3008 max). 23 | AthenaDisableSpillEncryption: 24 | Type: String 25 | Default: 'false' 26 | Description: 'WARNING: If set to ''true'' encryption for spilled data is disabled.' 27 | 28 | Transform: AWS::Serverless-2016-10-31 29 | Resources: 30 | AtheneCollector: 31 | Type: AWS::Serverless::Application 32 | Properties: 33 | Location: 34 | ApplicationId: arn:aws:serverlessrepo:us-east-1:292517598671:applications/AthenaDynamoDBConnector 35 | SemanticVersion: 2022.47.1 36 | Parameters: 37 | AthenaCatalogName: !Ref AthenaCatalogName 38 | DisableSpillEncryption: !Ref AthenaDisableSpillEncryption 39 | LambdaMemory: !Ref AthenaLambdaMemory 40 | LambdaTimeout: !Ref AthenaLambdaTimeout 41 | SpillBucket: !Ref AthenaSpilloverBucket 42 | SpillPrefix: !Ref AthenaSpilloverPrefix 43 | 44 | Outputs: 45 | AthenaCollectorArn: 46 | Value: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AthenaCatalogName}" 47 | -------------------------------------------------------------------------------- /cloudformation/delegated_centralization.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | AWSOrgId: 4 | Type: CommaDelimitedList 5 | Description: AWS Organizations ID 6 | AthenaCatalogName: 7 | Type: String 8 | Description: Name of the athena data catalog 9 | AthenaSpilloverBucket: 10 | Type: String 11 | Description: Bucket for the athena spillover 12 | COCAAssumeRoleName: 13 | Type: String 14 | COCACreateIAMRoles: 15 | Type: String 16 | COCAQuicksightRegion: 17 | Type: String 18 | COCABucket: 19 | Type: String 20 | COCAEventBridgeForwardRuleRoleName: 21 | Type: String 22 | COCAEventBridgeInvokeRuleRoleName: 23 | Type: String 24 | COCAAccountMapRoleName: 25 | Type: String 26 | COCASeederRoleName: 27 | Type: String 28 | COCAAccountMapLambdaRoleName: 29 | Type: String 30 | COCAPayers: 31 | Type: String 32 | 33 | Conditions: 34 | CreateIAMRole: !Equals 35 | - !Ref COCACreateIAMRoles 36 | - "yes" 37 | 38 | Resources: 39 | COCAEventBus: 40 | Type: 'AWS::Events::EventBus' 41 | Properties: 42 | Name: 'COCABus' 43 | 44 | COCAEventBusPolicy: 45 | Type: 'AWS::Events::EventBusPolicy' 46 | Properties: 47 | EventBusName: !Ref COCAEventBus 48 | StatementId: "allow_all_linked_accounts_to_push_events" 49 | Statement: 50 | Sid: "allow_all_linked_accounts_to_push_events" 51 | Effect: Allow 52 | Action: "events:PutEvents" 53 | Principal: "*" 54 | Condition: 55 | StringEquals: 56 | aws:PrincipalOrgID: !Ref AWSOrgId 57 | Resource: !GetAtt 58 | - COCAEventBus 59 | - Arn 60 | 61 | COCAEventBridgeInvokeEventBusRole: 62 | Type: 'AWS::IAM::Role' 63 | Metadata: 64 | cfn_nag: 65 | rules_to_suppress: 66 | - id: W28 67 | reason: IAM role requires explicit name 68 | Condition: CreateIAMRole 69 | Properties: 70 | RoleName: !Sub "${COCAEventBridgeForwardRuleRoleName}" 71 | AssumeRolePolicyDocument: 72 | Version: "2012-10-17" 73 | Statement: 74 | - Effect: Allow 75 | Principal: 76 | Service: 77 | - events.amazonaws.com 78 | Action: 79 | - 'sts:AssumeRole' 80 | Policies: 81 | - PolicyName: COCAEventBridgeInvokeEventBusRolePolicy 82 | PolicyDocument: 83 | Version: "2012-10-17" 84 | Statement: 85 | - Effect: Allow 86 | Action: 87 | - 'events:PutEvents' 88 | Resource: !GetAtt 89 | - COCAEventBus 90 | - Arn 91 | 92 | COCAEventBridgeForwardingRule: 93 | Type: 'AWS::Events::Rule' 94 | Properties: 95 | State: ENABLED 96 | EventPattern: 97 | source: 98 | - 'aws.support' 99 | detail-type: 100 | - 'Support Case Update' 101 | Targets: 102 | - Arn: !GetAtt 103 | - COCAEventBus 104 | - Arn 105 | Id: 'COCAEventBus' 106 | RoleArn: 107 | !If 108 | - CreateIAMRole 109 | - !GetAtt 110 | - COCAEventBridgeInvokeEventBusRole 111 | - Arn 112 | - !Sub "arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}:role/${COCAEventBridgeInvokeRuleRoleName}" 113 | 114 | COCALambdaExecutionRolePolicy: 115 | Type: 'AWS::IAM::Policy' 116 | Condition: CreateIAMRole 117 | Metadata: 118 | cfn_nag: 119 | rules_to_suppress: 120 | - id: W12 121 | reason: Policy contains actions(s) that only support * resource 122 | Properties: 123 | PolicyDocument: 124 | Statement: 125 | - Effect: Allow 126 | Action: 127 | - 'support:DescribeCases' 128 | - 'comprehend:DetectDominantLanguage' 129 | - 'comprehend:DetectSentiment' 130 | Resource: '*' 131 | Version: 2012-10-17 132 | PolicyName: COCALambdaExecutionRolePolicy-WildcardPermissions 133 | Roles: 134 | - Ref: COCALambdaExecutionRole 135 | 136 | COCALambdaExecutionRole: 137 | Type: 'AWS::IAM::Role' 138 | Metadata: 139 | cfn_nag: 140 | rules_to_suppress: 141 | - id: W28 142 | reason: IAM role requires explicit name 143 | Condition: CreateIAMRole 144 | Properties: 145 | RoleName: !Sub "${COCAEventBridgeInvokeRuleRoleName}" 146 | AssumeRolePolicyDocument: 147 | Version: "2012-10-17" 148 | Statement: 149 | - Effect: Allow 150 | Principal: 151 | Service: 152 | - lambda.amazonaws.com 153 | Action: 154 | - 'sts:AssumeRole' 155 | Policies: 156 | - PolicyName: COCALambda2DDB 157 | PolicyDocument: 158 | Version: "2012-10-17" 159 | Statement: 160 | - Effect: Allow 161 | Action: 162 | - 'dynamodb:PutItem' 163 | - 'dynamodb:GetItem' 164 | - 'dynamodb:UpdateItem' 165 | Resource: !GetAtt 166 | - COCADDBTable 167 | - Arn 168 | - PolicyName: COCALambdaSTS 169 | PolicyDocument: 170 | Version: "2012-10-17" 171 | Statement: 172 | - Effect: Allow 173 | Action: 174 | - 'sts:AssumeRole' 175 | Resource: !Sub "arn:${AWS::Partition}:iam::*:role/${COCAAssumeRoleName}" 176 | - PolicyName: COCALambdaLogs 177 | PolicyDocument: 178 | Version: "2012-10-17" 179 | Statement: 180 | - Effect: Allow 181 | Action: 182 | - 'logs:CreateLogGroup' 183 | - 'logs:CreateLogStream' 184 | - 'logs:PutLogEvents' 185 | Resource: 186 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" 187 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*:log-stream:*" 188 | 189 | COCADDBTable: 190 | Type: 'AWS::DynamoDB::Table' 191 | Metadata: 192 | cfn_nag: 193 | rules_to_suppress: 194 | - id: W28 195 | reason: DDB table requires explicit name, referenced in Lambda code 196 | Properties: 197 | TableName: "COCA" 198 | BillingMode: "PAY_PER_REQUEST" 199 | PointInTimeRecoverySpecification: 200 | PointInTimeRecoveryEnabled: true 201 | AttributeDefinitions: 202 | - 203 | AttributeName: 'caseId' 204 | AttributeType: 'S' 205 | KeySchema: 206 | - 207 | AttributeName: 'caseId' 208 | KeyType: HASH 209 | SSESpecification: 210 | SSEEnabled: true 211 | 212 | COCALambdaFunction: 213 | Type: 'AWS::Lambda::Function' 214 | Metadata: 215 | cfn_nag: 216 | rules_to_suppress: 217 | - id: W89 218 | reason: Lambda function not accessing any private resources 219 | - id: W92 220 | reason: Lambda function not used to compute critical workloads and low risk of throttle 221 | Properties: 222 | FunctionName: COCALambda 223 | Environment: 224 | Variables: 225 | COCAAssumeRole: !Ref COCAAssumeRoleName 226 | REGION: !Ref AWS::Region 227 | Architectures: 228 | - arm64 229 | Handler: index.lambda_handler 230 | Role: 231 | !If 232 | - CreateIAMRole 233 | - !GetAtt 234 | - COCALambdaExecutionRole 235 | - Arn 236 | - !Sub arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}:role/${COCAAssumeRoleName} 237 | Runtime: python3.9 238 | Code: 239 | ZipFile: | 240 | import datetime 241 | import os 242 | import boto3 243 | 244 | region = os.getenv("REGION", "us-east-1") 245 | sts = boto3.client("sts", region_name=region) 246 | ddb = boto3.resource("dynamodb", region_name=region) 247 | comprehend = boto3.client("comprehend", region_name=region) 248 | COCAAssumeRole = os.getenv("COCAAssumeRole", "COCAAssumeRole") 249 | ddbTable = "COCA" 250 | Sentiments = {"POSITIVE": 3, "NEUTRAL": 2, "MIXED": 1, "NEGATIVE": 0} 251 | 252 | 253 | def get_privileges(aws_account, context): 254 | """ 255 | assume role in linked account aws_account 256 | return credentials 257 | """ 258 | lambdaAccount = context.invoked_function_arn.split(":")[4] 259 | if aws_account == lambdaAccount: 260 | return False 261 | 262 | roleArn = "arn:aws:iam::" + aws_account + ":role/" + COCAAssumeRole 263 | print(f"assuming role {roleArn}") 264 | assumed_role = sts.assume_role(RoleArn=roleArn, RoleSessionName="COCALambdaAssumeRole") 265 | return assumed_role["Credentials"] 266 | 267 | 268 | def describe_case(case_id, credentials): 269 | """ 270 | describe case case_id using credentials 271 | return case dict 272 | """ 273 | print(f"describing case {case_id}") 274 | if credentials is False: 275 | sup = boto3.client("support") 276 | try: 277 | return sup.describe_cases(caseIdList=[case_id])["cases"][0] 278 | except: 279 | return False 280 | 281 | ACCESS_KEY = credentials["AccessKeyId"] 282 | SECRET_KEY = credentials["SecretAccessKey"] 283 | SESSION_TOKEN = credentials["SessionToken"] 284 | sup = boto3.client( 285 | "support", 286 | aws_access_key_id=ACCESS_KEY, 287 | aws_secret_access_key=SECRET_KEY, 288 | aws_session_token=SESSION_TOKEN, 289 | ) 290 | try: 291 | return sup.describe_cases(caseIdList=[case_id])["cases"][0] 292 | except: 293 | return False 294 | 295 | 296 | def insert_in_ddb(caseDict, aws_account): 297 | """ 298 | insert case case in ddb 299 | """ 300 | table = ddb.Table(ddbTable) 301 | caseItem={ 302 | "caseId": caseDict["caseId"], 303 | "awsAccount": aws_account, 304 | "displayId": caseDict["displayId"], 305 | "subject": caseDict["subject"], 306 | "caseStatus": caseDict["status"], 307 | "serviceCode": caseDict["serviceCode"], 308 | "categoryCode": caseDict["categoryCode"], 309 | "severityCode": caseDict["severityCode"], 310 | "submittedBy": caseDict["recentCommunications"]["communications"][-1]["submittedBy"], 311 | "timeCreated": caseDict["timeCreated"], 312 | "timeLastUpdated": caseDict["recentCommunications"]["communications"][0]["timeCreated"], 313 | "lastUpdatedBy": caseDict["recentCommunications"]["communications"][0]["submittedBy"], 314 | "nbReopens": 0, 315 | "nbAWSComms": 0, 316 | "sentimentTrend": -1, 317 | "nbCustomerComms": 0, 318 | "caseLanguage": "", 319 | "rtoMet": True, 320 | } 321 | if caseDict["status"] == 'resolved': 322 | caseItem["timeLastResolved"] = caseDict["recentCommunications"]["communications"][0]["timeCreated"] 323 | table.put_item(Item=caseItem) 324 | 325 | 326 | def get_sentiment_from_message(message, language): 327 | """ 328 | use comprehend to get sentiment from message 329 | """ 330 | if len(str(message)) > 0 and len(str(message)) < 5000: 331 | r = comprehend.detect_sentiment(Text=str(message), LanguageCode=language) 332 | return r["Sentiment"] 333 | else: 334 | return 'NEUTRAL' 335 | 336 | 337 | def get_dominant_language(message): 338 | """ 339 | use comprehend to get dominant language for message 340 | """ 341 | if len(str(message)) > 0 and len(str(message)) < 5000: 342 | r = comprehend.detect_dominant_language(Text=str(message)) 343 | return r["Languages"][0]["LanguageCode"] 344 | else: 345 | return 'en' 346 | 347 | def get_sentiment_trend(currentInfo,sentiment): 348 | if currentInfo == -1: 349 | return 0 350 | if currentInfo - sentiment > 0: 351 | return -1 352 | elif currentInfo - sentiment < 0: 353 | return 1 354 | else: 355 | return 0 356 | 357 | def is_rto_met(caseDict): 358 | """ 359 | return True if RTO is met 360 | """ 361 | rto_times = {"low": 1440, "normal": 720, "high": 240, "urgent": 60, "critical": 15} 362 | rto = rto_times[caseDict["severityCode"]] 363 | 364 | a = caseDict["timeCreated"] 365 | comms = caseDict["recentCommunications"]["communications"] 366 | for comm in comms: 367 | if comm["submittedBy"] == "Amazon Web Services": 368 | da = datetime.datetime.strptime(a, "%Y-%m-%dT%H:%M:%S.%fZ") 369 | db = datetime.datetime.strptime(comm["timeCreated"], "%Y-%m-%dT%H:%M:%S.%fZ") 370 | diff = db - da 371 | print(divmod(diff.total_seconds(), 60)[0]) 372 | if divmod(diff.total_seconds(), 60)[0] > rto: 373 | print(divmod(diff.total_seconds(), 60)[0]) 374 | return False 375 | return True 376 | 377 | 378 | def update_in_ddb(caseDict, ev): 379 | """ 380 | update case case in ddb 381 | """ 382 | updateExpression = "set " 383 | expressionAttributeValues = {} 384 | table = ddb.Table(ddbTable) 385 | currentInfo = table.get_item(Key={"caseId": caseDict["caseId"]})["Item"] 386 | 387 | # if origin is set we have an AddCommunicationToCase event 388 | # increment current value and build updateExpression as needed 389 | if ev["origin"]: 390 | if ev["origin"] == "AWS": 391 | updateExpression += "nbAWSComms = :c," 392 | commIncrement = currentInfo["nbAWSComms"] + 1 393 | if currentInfo["nbAWSComms"] == 0: 394 | print("verifying if we meet RTO") 395 | # this is the first message from AWS, compute RTO 396 | updateExpression += "rtoMet = :BOOL," 397 | expressionAttributeValues[":BOOL"] = is_rto_met(caseDict) 398 | 399 | elif ev["origin"] == "CUSTOMER": 400 | message = caseDict["recentCommunications"]["communications"][0]["body"] 401 | updateExpression += "nbCustomerComms = :c," 402 | commIncrement = currentInfo["nbCustomerComms"] + 1 403 | if currentInfo["caseLanguage"]: 404 | lang = currentInfo["caseLanguage"] 405 | else: 406 | lang = get_dominant_language(message) 407 | print(f"language is {lang}") 408 | sentiment = get_sentiment_from_message(message, lang) 409 | print(f"sentiment is {sentiment}") 410 | updateExpression += "sentiment = :S, caseLanguage = :L," 411 | expressionAttributeValues[":S"] = Sentiments[sentiment] 412 | expressionAttributeValues[":L"] = lang 413 | if currentInfo['sentimentTrend'] != -1: 414 | updateExpression += "sentimentTrend = :ST," 415 | expressionAttributeValues[":ST"] = get_sentiment_trend(currentInfo['sentiment'],Sentiments[sentiment]) 416 | expressionAttributeValues[":c"] = commIncrement 417 | 418 | updateExpression += "timeLastUpdated = :t," 419 | expressionAttributeValues[":t"] = caseDict["recentCommunications"]["communications"][0]["timeCreated"] 420 | updateExpression += "lastUpdatedBy = :u," 421 | expressionAttributeValues[":u"] = caseDict["recentCommunications"]["communications"][0]["submittedBy"] 422 | 423 | # we are resolving this case 424 | if caseDict["status"] == "resolved": 425 | updateExpression += "timeLastResolved = :tr," 426 | expressionAttributeValues[":tr"] = caseDict["recentCommunications"]["communications"][0]["timeCreated"] 427 | 428 | # if we're in this function with currentInfo['status'] set to resolved, 429 | # it is a case being reopened. 430 | if currentInfo["caseStatus"] == "resolved": 431 | updateExpression += "nbReopens = :r," 432 | expressionAttributeValues[":r"] = currentInfo["nbReopens"] + 1 433 | 434 | expressionAttributeValues[":s"] = caseDict["status"] 435 | table.update_item( 436 | Key={"caseId": caseDict["caseId"]}, 437 | UpdateExpression=updateExpression + "caseStatus = :s", 438 | ExpressionAttributeValues=expressionAttributeValues, 439 | ) 440 | 441 | 442 | def parse_event(event): 443 | """ 444 | parse the even we get from eventbus 445 | """ 446 | ev = {} 447 | ev["awsAccount"] = event["account"] 448 | ev["caseId"] = event["detail"]["case-id"] 449 | ev["eventName"] = event["detail"]["event-name"] 450 | """ 451 | possible event names 452 | - CreateCase 453 | - AddCommunicationToCase 454 | - happens when AWS or Customer adds communication to case, see origin 455 | - happens also when case is reopened 456 | - ResolveCase 457 | """ 458 | ev["origin"] = event["detail"]["origin"] 459 | """ 460 | Origin is null if even-name is not AddCommunicationToCase. 461 | its value can be AWS or CUSTOMER 462 | """ 463 | return ev 464 | 465 | 466 | def lambda_handler(event, context): 467 | """ 468 | our aptly named lambda handler 469 | """ 470 | ev = parse_event(event) 471 | print(ev) 472 | aws_account = ev["awsAccount"] 473 | case_id = ev["caseId"] 474 | event_name = ev["eventName"] 475 | c = get_privileges(aws_account, context) 476 | caseDict = describe_case(case_id, c) 477 | if not caseDict: 478 | return False 479 | print(caseDict) 480 | if event_name == "CreateCase": 481 | insert_in_ddb(caseDict, aws_account) 482 | elif event_name == "AddCommunicationToCase" and ev["origin"]: 483 | update_in_ddb(caseDict, ev) 484 | return True 485 | 486 | COCALambdaFunctionLogs: 487 | Type: 'AWS::Logs::LogGroup' 488 | Metadata: 489 | cfn_nag: 490 | rules_to_suppress: 491 | - id: W84 492 | reason: Lambda logs don't contain critical application data 493 | Properties: 494 | LogGroupName: /aws/lambda/COCALambda 495 | RetentionInDays: 7 496 | 497 | COCAEventBusRule: 498 | Type: 'AWS::Events::Rule' 499 | Properties: 500 | State: ENABLED 501 | EventBusName: !GetAtt 502 | - COCAEventBus 503 | - Arn 504 | EventPattern: 505 | source: 506 | - 'aws.support' 507 | detail-type: 508 | - 'Support Case Update' 509 | Targets: 510 | - Arn: !GetAtt 511 | - COCALambdaFunction 512 | - Arn 513 | Id: "LambdaFunction4COCA" 514 | PermissionForEventsToInvokeLambda: 515 | Type: 'AWS::Lambda::Permission' 516 | Properties: 517 | FunctionName: 518 | Ref: "COCALambdaFunction" 519 | Action: "lambda:InvokeFunction" 520 | Principal: "events.amazonaws.com" 521 | SourceArn: !GetAtt 522 | - COCAEventBusRule 523 | - Arn 524 | 525 | COCACustomResource: 526 | Type: Custom::CustomResource 527 | DependsOn: PermissionForEventsToInvokeLambda 528 | Properties: 529 | ServiceToken: !GetAtt 530 | - COCASeederFunction 531 | - Arn 532 | 533 | COCASeederRole: 534 | Type: AWS::IAM::Role 535 | Condition: CreateIAMRole 536 | Properties: 537 | AssumeRolePolicyDocument: 538 | Version: "2012-10-17" 539 | Statement: 540 | - Effect: Allow 541 | Principal: 542 | Service: 543 | - lambda.amazonaws.com 544 | Action: 545 | - "sts:AssumeRole" 546 | Policies: 547 | - PolicyName: CreateResolveCase 548 | PolicyDocument: 549 | Version: "2012-10-17" 550 | Statement: 551 | - Effect: Allow 552 | Action: 553 | - "logs:CreateLogGroup" 554 | - "logs:CreateLogStream" 555 | - "logs:PutLogEvents" 556 | Resource: 557 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" 558 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*" 559 | - Effect: Allow 560 | Action: 561 | - "dynamodb:PutItem" 562 | Resource: !GetAtt 563 | - COCADDBTable 564 | - Arn 565 | - Effect: Allow 566 | Action: 567 | - "lambda:InvokeFunction" 568 | Resource: !GetAtt 569 | - COCAAccountMapLambda 570 | - Arn 571 | 572 | COCASeederFunction: 573 | Type: AWS::Lambda::Function 574 | Metadata: 575 | cfn_nag: 576 | rules_to_suppress: 577 | - id: W89 578 | reason: Lambda function not accessing any private resources 579 | - id: W92 580 | reason: Lambda function not used to compute critical workloads and low risk of throttle 581 | Properties: 582 | Environment: 583 | Variables: 584 | ACCOUNT: !Ref AWS::AccountId 585 | Handler: index.handler 586 | Runtime: python3.9 587 | Timeout: 900 588 | Role: 589 | !If 590 | - CreateIAMRole 591 | - !GetAtt 592 | - COCASeederRole 593 | - Arn 594 | - !Sub "arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}:role/${COCASeederRoleName}" 595 | Code: 596 | ZipFile: | 597 | import os 598 | import boto3 599 | import cfnresponse 600 | from botocore.config import Config 601 | 602 | account = os.getenv("ACCOUNT","3133731337") 603 | ddb = boto3.resource('dynamodb') 604 | ddbTable = "COCA" 605 | def insert_case(): 606 | table = ddb.Table(ddbTable) 607 | caseItem={ 608 | 'caseId': 'case-deadbeef-deadbeef', 609 | 'awsAccount': account, 610 | 'displayId': '3133731337', 611 | 'subject': 'dummy case', 612 | 'caseStatus': 'resolved', 613 | 'serviceCode':'account-management', 614 | 'categoryCode': 'account-verification', 615 | 'severityCode': 'low', 616 | 'submittedBy': 'seederFunction', 617 | 'timeCreated': '2023-09-14T12:29:38.298Z', 618 | 'timeLastUpdated': '2023-09-14T12:29:38.298Z', 619 | 'timeLastResolved': '2023-09-14T12:29:38.298Z', 620 | 'lastUpdatedBy': 'AWS', 621 | 'nbReopens': 1, 622 | 'nbAWSComms': 1, 623 | 'nbCustomerComms': 1, 624 | 'sentiment': 0, 625 | 'sentimentTrend': 0, 626 | 'caseLanguage': 'en', 627 | 'rtoMet': True, 628 | } 629 | table.put_item(Item=caseItem) 630 | def handler(e,v): 631 | print(e) 632 | responseValue = e['ResourceProperties'] 633 | responseData = {} 634 | responseData['Data'] = responseValue 635 | if e.get('RequestType') == 'Create': 636 | insert_case() 637 | l = boto3.client('lambda', config=Config(tcp_keepalive=True,read_timeout=900)) 638 | r = l.invoke( 639 | FunctionName="COCAAccountMapLambda", 640 | InvocationType='RequestResponse', 641 | LogType='None' 642 | ) 643 | cfnresponse.send(e, v, cfnresponse.SUCCESS, responseData) 644 | 645 | COCAAccountMapLambdaRolePolicy: 646 | Type: 'AWS::IAM::Policy' 647 | Condition: CreateIAMRole 648 | Metadata: 649 | cfn_nag: 650 | rules_to_suppress: 651 | - id: W12 652 | reason: Policy contains actions(s) that only support * resource 653 | Properties: 654 | PolicyDocument: 655 | Statement: 656 | - Effect: Allow 657 | Action: 658 | - 'athena:StartQueryExecution' 659 | - 'athena:GetDataCatalog' 660 | Resource: '*' 661 | Version: 2012-10-17 662 | PolicyName: COCAAccountMapRolePolicy-WildcardPermissions 663 | Roles: 664 | - Ref: COCAAccountMapLambdaRole 665 | 666 | COCAAccountMapLambdaRole: 667 | Type: AWS::IAM::Role 668 | Metadata: 669 | cfn_nag: 670 | rules_to_suppress: 671 | - id: W28 672 | reason: IAM role requires explicit name 673 | Condition: CreateIAMRole 674 | Properties: 675 | AssumeRolePolicyDocument: 676 | Version: "2012-10-17" 677 | Statement: 678 | - Effect: Allow 679 | Principal: 680 | Service: 681 | - lambda.amazonaws.com 682 | Action: 683 | - 'sts:AssumeRole' 684 | RoleName: !Sub "${COCAAccountMapLambdaRoleName}" 685 | Policies: 686 | - PolicyName: COCAAccountMapSTS 687 | PolicyDocument: 688 | Version: "2012-10-17" 689 | Statement: 690 | - Effect: Allow 691 | Action: 692 | - 'sts:AssumeRole' 693 | Resource: !Sub "arn:${AWS::Partition}:iam::*:role/${COCAAccountMapRoleName}" 694 | - PolicyName: COCAAccountMapGlue 695 | PolicyDocument: 696 | Version: "2012-10-17" 697 | Statement: 698 | - Effect: Allow 699 | Action: 700 | - 'glue:CreateDatabase' 701 | - 'glue:GetDatabase' 702 | - 'glue:GetTable' 703 | - 'glue:CreateTable' 704 | - 'glue:UpdateTable' 705 | - 'glue:GetTable' 706 | Resource: 707 | - !Sub "arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:catalog" 708 | - !Sub "arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:database/cocaaccountmap" 709 | - !Sub "arn:${AWS::Partition}:glue:${AWS::Region}:${AWS::AccountId}:table/cocaaccountmap/account_map" 710 | - PolicyName: COCAAccountMapS3 711 | PolicyDocument: 712 | Version: "2012-10-17" 713 | Statement: 714 | - Effect: Allow 715 | Action: 716 | - 's3:PutObject' 717 | - 's3:GetBucketLocation' 718 | Resource: 719 | - !Sub "arn:${AWS::Partition}:s3:::${AthenaSpilloverBucket}/*" 720 | - !Sub "arn:${AWS::Partition}:s3:::${AthenaSpilloverBucket}" 721 | - PolicyName: COCAAccountMapLogs 722 | PolicyDocument: 723 | Version: "2012-10-17" 724 | Statement: 725 | - Effect: Allow 726 | Action: 727 | - 'logs:CreateLogGroup' 728 | - 'logs:CreateLogStream' 729 | - 'logs:PutLogEvents' 730 | Resource: 731 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" 732 | - !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*" 733 | 734 | COCAAccountMapLambda: 735 | Type: AWS::Lambda::Function 736 | Metadata: 737 | cfn_nag: 738 | rules_to_suppress: 739 | - id: W89 740 | reason: Lambda function not accessing any private resources 741 | - id: W92 742 | reason: Lambda function not used to compute critical workloads and low risk of throttle 743 | Properties: 744 | FunctionName: COCAAccountMapLambda 745 | Environment: 746 | Variables: 747 | COCAAccountMapRole: !Ref COCAAccountMapRoleName 748 | COCAPayers: !Ref COCAPayers 749 | Handler: index.handler 750 | Runtime: python3.9 751 | Timeout: 900 752 | Role: 753 | !If 754 | - CreateIAMRole 755 | - !GetAtt 756 | - COCAAccountMapLambdaRole 757 | - Arn 758 | - !Sub arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}:role/${COCAAccountMapRoleName} 759 | Code: 760 | ZipFile: | 761 | import os 762 | import boto3 763 | 764 | COCAAccountMapRole = os.getenv("COCAAccountMapRole", "COCAAccountMapRole") 765 | COCAPayers = os.getenv("COCAPayers") 766 | 767 | sts = boto3.client('sts') 768 | org = boto3.client('organizations') 769 | ath = boto3.client('athena') 770 | 771 | class Account: 772 | def __init__(self,Id,Name,Status,Parent,Payer): 773 | self.Id = Id 774 | self.Name = Name 775 | self.Status = Status 776 | self.Parent = Parent 777 | self.Payer = Payer 778 | 779 | def get_root_id(client): 780 | ''' 781 | Get root ID for Organizations with payer 'payer' 782 | ''' 783 | return client.list_roots()['Roots'][0]['Id'] 784 | 785 | def get_recursive_ous(parent, ou_list, client): 786 | ''' 787 | Recursively an OU tree and return a list of OUs 788 | ''' 789 | pa = client.get_paginator('list_children') 790 | it = pa.paginate(ParentId=parent, ChildType='ORGANIZATIONAL_UNIT') 791 | for p in it: 792 | for ou in p['Children']: 793 | ou_list.append(ou['Id']) 794 | get_recursive_ous(ou['Id'], ou_list, client) 795 | return ou_list 796 | 797 | def get_ous(parent,client): 798 | ''' 799 | return a dict with all OUs in an AWS Organization 800 | ''' 801 | ou_tree = {} 802 | ou_list = [] 803 | ous = get_recursive_ous(parent, ou_list, client) 804 | for ou in ous: 805 | ou_tree[ou] = [] 806 | ou_tree[ou].append(client.describe_organizational_unit(OrganizationalUnitId=ou)) 807 | return ou_tree 808 | 809 | def get_account_map(parent, parent_name, client,account_map, payer): 810 | ''' 811 | get all accounts under parent 'parent' 812 | return a list of accounts in 'account_nap' 813 | ''' 814 | account_list = [] 815 | pa = client.get_paginator('list_accounts_for_parent') 816 | it = pa.paginate(ParentId=parent) 817 | for p in it: 818 | for account in p['Accounts']: 819 | account_list.append(account['Id']) 820 | 821 | for account_id in account_list: 822 | account = client.describe_account(AccountId=account_id)['Account'] 823 | account_map.append(Account(account['Id'],account['Name'],account['Status'],parent_name,payer)) 824 | return account_map 825 | 826 | def create_athena_table(ath_client): 827 | sql = "create database if not exists cocaaccountmap;" 828 | 829 | response = ath_client.start_query_execution( 830 | QueryString=sql, 831 | QueryExecutionContext={ 832 | 'Catalog': 'AwsDataCatalog' 833 | }, 834 | WorkGroup = 'COCAWorkGroup' 835 | ) 836 | 837 | def update_athena_view(account_map, ath_client): 838 | ''' 839 | creates or update a view in Athena with the content of account_map 840 | ''' 841 | sql = "create or replace view account_map as select * from ( values " 842 | i = 0 843 | for a in account_map: 844 | if i == 0: 845 | i += 1 846 | sql += "ROW ( '{0}', '{1}', '{2}', '{3}', '{4}' )\n".format( 847 | a.Id, 848 | a.Name, 849 | a.Status, 850 | a.Parent, 851 | a.Payer) 852 | else: 853 | sql += ",ROW ( '{0}', '{1}', '{2}', '{3}', '{4}' )\n".format( 854 | a.Id, 855 | a.Name, 856 | a.Status, 857 | a.Parent, 858 | a.Payer) 859 | sql += ") ignored_table_name (Id, name, status, parent, payer_id)" 860 | response = ath_client.start_query_execution( 861 | QueryString=sql, 862 | QueryExecutionContext={ 863 | 'Database': 'cocaaccountmap', 864 | 'Catalog': 'AwsDataCatalog' 865 | }, 866 | WorkGroup = 'COCAWorkGroup' 867 | ) 868 | 869 | def get_credentials(payer): 870 | role_arn = "arn:aws:iam::"+payer+":role/"+COCAAccountMapRole 871 | assumed_role = sts.assume_role( 872 | RoleArn = role_arn, 873 | RoleSessionName = "COCAAccountMapRole" 874 | ) 875 | return assumed_role['Credentials'] 876 | 877 | def get_clients(payer): 878 | runningAccount = sts.get_caller_identity()['Account'] 879 | if payer == runningAccount: 880 | org = boto3.client('organizations') 881 | else: 882 | creds = get_credentials(payer) 883 | org = boto3.client('organizations', 884 | aws_access_key_id = creds['AccessKeyId'], 885 | aws_secret_access_key = creds['SecretAccessKey'], 886 | aws_session_token = creds['SessionToken'] 887 | ) 888 | return org 889 | 890 | 891 | def handler(event, context): 892 | account_map = [] 893 | for payer in COCAPayers.split(','): 894 | org_client = get_clients(payer) 895 | rootId = get_root_id(org_client) 896 | ous = get_ous(rootId, org_client) 897 | 898 | for ou in ous.keys(): 899 | ou_name = ous[ou][0]['OrganizationalUnit']['Name'] 900 | account_map = get_account_map(ou, ou_name, org_client, account_map, payer) 901 | 902 | account_map = get_account_map(rootId, rootId, org_client, account_map, payer) 903 | create_athena_table(ath) 904 | update_athena_view(account_map, ath) 905 | 906 | COCAAccountMapEventBusRule: 907 | Type: AWS::Events::Rule 908 | Properties: 909 | Description: "Rebuild COCA account map on a periodic basis" 910 | Name: COCAAccountMapEventRule 911 | State: ENABLED 912 | ScheduleExpression: "rate(1 day)" 913 | Targets: 914 | - 915 | Arn: !GetAtt 916 | - COCAAccountMapLambda 917 | - Arn 918 | Id: "COCAAccountMapLambdaInvocation" 919 | 920 | COCAPermissionsForEventsToInvokeAccountMapLambda: 921 | Type: AWS::Lambda::Permission 922 | Properties: 923 | FunctionName: 924 | Ref: "COCAAccountMapLambda" 925 | Action: 'lambda:InvokeFunction' 926 | Principal: 'events.amazonaws.com' 927 | SourceArn: !GetAtt 928 | - COCAAccountMapEventBusRule 929 | - Arn 930 | 931 | COCAAccountMapGlueDatabase: 932 | Type: AWS::Glue::Database 933 | Properties: 934 | CatalogId: !Sub "${AWS::AccountId}" 935 | DatabaseInput: 936 | Name: "cocaaccountmap" 937 | CreateTableDefaultPermissions: 938 | - 939 | Permissions: 940 | - "ALL" 941 | Principal: 942 | DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS" 943 | 944 | 945 | COCAAccountMapGlueTable: 946 | Type: AWS::Glue::Table 947 | DependsOn: COCAAccountMapGlueDatabase 948 | Properties: 949 | CatalogId: !Sub "${AWS::AccountId}" 950 | DatabaseName: "cocaaccountmap" 951 | TableInput: 952 | Name: "account_map" 953 | Retention: 0 954 | TableType: VIRTUAL_VIEW 955 | Parameters: 956 | presto_view: true 957 | PartitionKeys: [] 958 | ViewOriginalText: 959 | "Fn::Sub": 960 | - "/* Presto View: ${pview} */" 961 | - 962 | pview: 963 | "Fn::Base64": !Sub ' 964 | { 965 | "catalog": "awsdatacatalog", 966 | "schema": "cocaaccountmap", 967 | "columns": [ 968 | { 969 | "name": "id", 970 | "type": "varchar" 971 | }, 972 | { 973 | "name": "name", 974 | "type": "varchar" 975 | }, 976 | { 977 | "name": "status", 978 | "type": "varchar" 979 | }, 980 | { 981 | "name": "parent", 982 | "type": "varchar" 983 | }, 984 | { 985 | "name": "payer_id", 986 | "type": "varchar" 987 | } 988 | ], 989 | "originalSql": "CREATE OR REPLACE VIEW account_map AS 990 | SELECT * FROM ( VALUES 991 | ROW ( ${AWS::AccountId}, test, ACTIVE, test ) 992 | )" 993 | } 994 | ' 995 | StorageDescriptor: 996 | SerdeInfo: {} 997 | Compressed: False 998 | StoredAsSubDirectories: False 999 | Columns: 1000 | - 1001 | Name: "id" 1002 | Type: "string" 1003 | - 1004 | Name: "name" 1005 | Type: "string" 1006 | - 1007 | Name: "status" 1008 | Type: "string" 1009 | - 1010 | Name: "parent" 1011 | Type: "string" 1012 | - 1013 | Name: "payer_id" 1014 | Type: "string" 1015 | 1016 | 1017 | COCAAthenaTransform: 1018 | Type: 'AWS::CloudFormation::Stack' 1019 | Properties: 1020 | TemplateURL: !Sub "https://${COCABucket}.s3.amazonaws.com/athena_transform.yml" 1021 | Parameters: 1022 | AthenaCatalogName: !Ref AthenaCatalogName 1023 | AthenaSpilloverBucket: !Ref AthenaSpilloverBucket 1024 | COCAAthenaDataCatalog: 1025 | Type: 'AWS::Athena::DataCatalog' 1026 | DependsOn: COCACustomResource 1027 | Properties: 1028 | Name: "COCA-datasource" 1029 | Type: LAMBDA 1030 | Parameters: 1031 | function: !GetAtt COCAAthenaTransform.Outputs.AthenaCollectorArn 1032 | 1033 | COCAAthenaWorkgroup: 1034 | Type: AWS::Athena::WorkGroup 1035 | Properties: 1036 | Name: "COCAWorkGroup" 1037 | State: "ENABLED" 1038 | RecursiveDeleteOption: true 1039 | WorkGroupConfiguration: 1040 | EnforceWorkGroupConfiguration: true 1041 | ResultConfiguration: 1042 | OutputLocation: !Sub "s3://${AthenaSpilloverBucket}/" 1043 | 1044 | COCAQuicksightDatasource: 1045 | Type: AWS::QuickSight::DataSource 1046 | DependsOn: COCAAthenaDataCatalog 1047 | Properties: 1048 | AwsAccountId: !Sub "${AWS::AccountId}" 1049 | Type: "ATHENA" 1050 | DataSourceId: "COCA-datasource" 1051 | Name: "COCADataSource" 1052 | DataSourceParameters: 1053 | AthenaParameters: 1054 | WorkGroup: "COCAWorkGroup" 1055 | 1056 | COCAQuicksightDatasourceAccountMap: 1057 | Type: AWS::QuickSight::DataSource 1058 | DependsOn: COCAAccountMapGlueTable 1059 | Properties: 1060 | AwsAccountId: !Sub "${AWS::AccountId}" 1061 | Type: "ATHENA" 1062 | DataSourceId: "COCAAccountMap" 1063 | Name: "COCAAccountMap" 1064 | DataSourceParameters: 1065 | AthenaParameters: 1066 | WorkGroup: "COCAWorkGroup" 1067 | 1068 | 1069 | COCAQuicksightDataset: 1070 | Type: AWS::QuickSight::DataSet 1071 | Properties: 1072 | Name: "COCAQuicksightDataset" 1073 | AwsAccountId: !Sub "${AWS::AccountId}" 1074 | Permissions: 1075 | - 1076 | Actions: 1077 | - "quicksight:ListIngestions" 1078 | - "quicksight:DeleteDataSet" 1079 | - "quicksight:UpdateDataSetPermissions" 1080 | - "quicksight:CancelIngestion" 1081 | - "quicksight:DescribeDataSetPermissions" 1082 | - "quicksight:UpdateDataSet" 1083 | - "quicksight:DescribeDataSet" 1084 | - "quicksight:PassDataSet" 1085 | - "quicksight:DescribeIngestion" 1086 | - "quicksight:CreateIngestion" 1087 | Principal: !Sub "arn:${AWS::Partition}:quicksight:${COCAQuicksightRegion}:${AWS::AccountId}:group/default/COCAAdmin" 1088 | ImportMode: "SPICE" 1089 | DataSetId: "COCAQuicksightDataset" 1090 | PhysicalTableMap: 1091 | COCAAccountMapTableMap: 1092 | RelationalTable: 1093 | Catalog: "AwsDataCatalog" 1094 | Schema: "cocaaccountmap" 1095 | Name: "account_map" 1096 | DataSourceArn: !GetAtt 1097 | - COCAQuicksightDatasourceAccountMap 1098 | - Arn 1099 | InputColumns: 1100 | - 1101 | Name: "id" 1102 | Type: "STRING" 1103 | - 1104 | Name: "name" 1105 | Type: "STRING" 1106 | - 1107 | Name: "status" 1108 | Type: "STRING" 1109 | - 1110 | Name: "parent" 1111 | Type: "STRING" 1112 | - 1113 | Name: "payer_id" 1114 | Type: "STRING" 1115 | COCAPhysicalTableMap: 1116 | RelationalTable: 1117 | Schema: "default" 1118 | Catalog: "COCA-datasource" 1119 | Name: "coca" 1120 | DataSourceArn: !GetAtt 1121 | - COCAQuicksightDatasource 1122 | - Arn 1123 | InputColumns: 1124 | - 1125 | Name: "submittedBy" 1126 | Type: "STRING" 1127 | - 1128 | Name: "timeLastUpdated" 1129 | Type: "STRING" 1130 | - 1131 | Name: "lastUpdatedBy" 1132 | Type: "STRING" 1133 | - 1134 | Name: "nbReopens" 1135 | Type: "DECIMAL" 1136 | - 1137 | Name: "sentiment" 1138 | Type: "DECIMAL" 1139 | - 1140 | Name: "serviceCode" 1141 | Type: "STRING" 1142 | - 1143 | Name: "subject" 1144 | Type: "STRING" 1145 | - 1146 | Name: "awsAccount" 1147 | Type: "STRING" 1148 | - 1149 | Name: "caseStatus" 1150 | Type: "STRING" 1151 | - 1152 | Name: "nbCustomerComms" 1153 | Type: "DECIMAL" 1154 | - 1155 | Name: "categoryCode" 1156 | Type: "STRING" 1157 | - 1158 | Name: "nbAWSComms" 1159 | Type: "DECIMAL" 1160 | - 1161 | Name: "caseLanguage" 1162 | Type: "STRING" 1163 | - 1164 | Name: "rtoMet" 1165 | Type: "BOOLEAN" 1166 | - 1167 | Name: "caseId" 1168 | Type: "STRING" 1169 | - 1170 | Name: "sentimentTrend" 1171 | Type: "DECIMAL" 1172 | - 1173 | Name: "timeCreated" 1174 | Type: "STRING" 1175 | - 1176 | Name: "timeLastResolved" 1177 | Type: "STRING" 1178 | - 1179 | Name: "severityCode" 1180 | Type: "STRING" 1181 | - 1182 | Name: "displayId" 1183 | Type: "STRING" 1184 | 1185 | LogicalTableMap: 1186 | COCALogicaTableAccountMap: 1187 | Alias: "account_map" 1188 | Source: 1189 | PhysicalTableId: "COCAAccountMapTableMap" 1190 | COCALogicalTableMap: 1191 | Alias: "cocatable" 1192 | Source: 1193 | PhysicalTableId: "COCAPhysicalTableMap" 1194 | DataTransforms: 1195 | - 1196 | CastColumnTypeOperation: 1197 | ColumnName: "timeCreated" 1198 | NewColumnType: "DATETIME" 1199 | Format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 1200 | - 1201 | CastColumnTypeOperation: 1202 | ColumnName: "timeLastUpdated" 1203 | NewColumnType: "DATETIME" 1204 | Format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 1205 | - 1206 | CastColumnTypeOperation: 1207 | ColumnName: "timeLastResolved" 1208 | NewColumnType: "DATETIME" 1209 | Format: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 1210 | COCAIntermediateTable: 1211 | Alias: "Intermediate Table" 1212 | Source: 1213 | JoinInstruction: 1214 | LeftOperand: "COCALogicalTableMap" 1215 | RightOperand: "COCALogicaTableAccountMap" 1216 | Type: "LEFT" 1217 | OnClause: "{awsAccount} = {id}" 1218 | DataTransforms: 1219 | - 1220 | ProjectOperation: 1221 | ProjectedColumns: 1222 | - "submittedBy" 1223 | - "lastUpdatedBy" 1224 | - "timeLastUpdated" 1225 | - "timeLastResolved" 1226 | - "nbReopens" 1227 | - "sentiment" 1228 | - "serviceCode" 1229 | - "subject" 1230 | - "awsAccount" 1231 | - "caseStatus" 1232 | - "nbCustomersComms" 1233 | - "categoryCode" 1234 | - "nbAWSComms" 1235 | - "caseLanguage" 1236 | - "rtoMet" 1237 | - "caseId" 1238 | - "sentimentTrend" 1239 | - "timeCreated" 1240 | - "severityCode" 1241 | - "displayId" 1242 | - "id" 1243 | - "name" 1244 | - "status" 1245 | - "parent" 1246 | - "payer_id" 1247 | 1248 | COCAQuicksightDatasetRefreshSchedule: 1249 | Type: AWS::QuickSight::RefreshSchedule 1250 | DependsOn: COCAQuicksightDataset 1251 | Properties: 1252 | AwsAccountId: !Sub "${AWS::AccountId}" 1253 | DataSetId: "COCAQuicksightDataset" 1254 | Schedule: 1255 | RefreshType: "FULL_REFRESH" 1256 | ScheduleId: "COCAScheduledRefresh" 1257 | ScheduleFrequency: 1258 | Interval: "DAILY" 1259 | TimeOfTheDay: "04:00" 1260 | TimeZone: "Europe/Paris" 1261 | 1262 | COCADashboard: 1263 | Type: AWS::QuickSight::Dashboard 1264 | Properties: 1265 | Name: "COCADashboard" 1266 | DashboardId: "COCADashboard" 1267 | AwsAccountId: !Sub "${AWS::AccountId}" 1268 | Permissions: 1269 | - 1270 | Actions: 1271 | - "quicksight:DescribeDashboard" 1272 | - "quicksight:ListDashboardVersions" 1273 | - "quicksight:UpdateDashboardPermissions" 1274 | - "quicksight:QueryDashboard" 1275 | - "quicksight:UpdateDashboard" 1276 | - "quicksight:DeleteDashboard" 1277 | - "quicksight:DescribeDashboardPermissions" 1278 | - "quicksight:UpdateDashboardPublishedVersion" 1279 | Principal: !Sub "arn:${AWS::Partition}:quicksight:${COCAQuicksightRegion}:${AWS::AccountId}:group/default/COCAAdmin" 1280 | SourceEntity: 1281 | SourceTemplate: 1282 | Arn: "arn:aws:quicksight:us-east-1:712283339575:template/coca-template/version/11" 1283 | DataSetReferences: 1284 | - 1285 | DataSetArn: !GetAtt 1286 | - COCAQuicksightDataset 1287 | - Arn 1288 | DataSetPlaceholder: "COCAQuicksightDataset" 1289 | -------------------------------------------------------------------------------- /cloudformation/linkedaccounts_collection.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Parameters: 3 | CentralAccountId: 4 | Type: String 5 | CentralAccountEventBusName: 6 | Type: String 7 | Default: "COCABus" 8 | COCAAssumeRoleName: 9 | Type: String 10 | Default: "COCAAssumeRole" 11 | COCACreateIAMRoles: 12 | Type: String 13 | AllowedValues: ["yes","no"] 14 | Default: "yes" 15 | COCAEventBridgeForwardRuleRoleName: 16 | Type: String 17 | Default: "COCAForwardRuleRole" 18 | 19 | Conditions: 20 | CreateIAMRole: !Equals 21 | - !Ref COCACreateIAMRoles 22 | - "yes" 23 | 24 | Resources: 25 | COCAAssumeRolePolicy: 26 | Type: 'AWS::IAM::Policy' 27 | Condition: CreateIAMRole 28 | Metadata: 29 | cfn_nag: 30 | rules_to_suppress: 31 | - id: W12 32 | reason: Policy contains actions(s) that only support * resource 33 | Properties: 34 | PolicyDocument: 35 | Statement: 36 | - Effect: Allow 37 | Action: 38 | - 'support:DescribeCases' 39 | Resource: '*' 40 | Version: 2012-10-17 41 | PolicyName: COCAAssumeRolePolicy-WildcardPermissions 42 | Roles: 43 | - Ref: COCAAssumeRole 44 | 45 | COCAAssumeRole: 46 | Type: 'AWS::IAM::Role' 47 | Metadata: 48 | cfn_nag: 49 | rules_to_suppress: 50 | - id: W28 51 | reason: IAM role requires explicit name 52 | Condition: CreateIAMRole 53 | Properties: 54 | AssumeRolePolicyDocument: 55 | Version: "2012-10-17" 56 | Statement: 57 | - Effect: Allow 58 | Principal: 59 | AWS: !Sub "arn:${AWS::Partition}:iam::${CentralAccountId}:root" 60 | Action: 61 | - 'sts:AssumeRole' 62 | RoleName: !Sub "${COCAAssumeRoleName}" 63 | 64 | COCAEventBridgeInvokeEventBusRole: 65 | Type: 'AWS::IAM::Role' 66 | Metadata: 67 | cfn_nag: 68 | rules_to_suppress: 69 | - id: W28 70 | reason: IAM role requires explicit name 71 | Condition: CreateIAMRole 72 | Properties: 73 | RoleName: !Sub "${COCAEventBridgeForwardRuleRoleName}" 74 | AssumeRolePolicyDocument: 75 | Version: "2012-10-17" 76 | Statement: 77 | - Effect: Allow 78 | Principal: 79 | Service: 80 | - events.amazonaws.com 81 | Action: 82 | - 'sts:AssumeRole' 83 | Policies: 84 | - PolicyName: COCAEventBridgeInvokeEventBusRolePolicy 85 | PolicyDocument: 86 | Version: "2012-10-17" 87 | Statement: 88 | - Effect: Allow 89 | Action: 90 | - 'events:PutEvents' 91 | Resource: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 92 | COCAEventBridgeForwardingRule: 93 | Type: 'AWS::Events::Rule' 94 | Properties: 95 | Description: Routes to AWS Support events to central account event bus 96 | State: ENABLED 97 | EventPattern: 98 | source: 99 | - 'aws.support' 100 | detail-type: 101 | - 'Support Case Update' 102 | Targets: 103 | - Arn: !Sub "arn:${AWS::Partition}:events:${AWS::Region}:${CentralAccountId}:event-bus/${CentralAccountEventBusName}" 104 | Id: !Sub ${CentralAccountEventBusName} 105 | RoleArn: 106 | !If 107 | - CreateIAMRole 108 | - !GetAtt 109 | - COCAEventBridgeInvokeEventBusRole 110 | - Arn 111 | - !Sub arn:${AWS::Partition}:iam:${AWS::Region}:${AWS::AccountId}}:role/${COCAEventBridgeForwardRuleRoleName} 112 | -------------------------------------------------------------------------------- /img/coca-multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/consolidated-organizations-wide-cases-activity/e733335c9a37515f28dbe90aa1d3f02279512e83/img/coca-multi.png -------------------------------------------------------------------------------- /img/coca-standard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/consolidated-organizations-wide-cases-activity/e733335c9a37515f28dbe90aa1d3f02279512e83/img/coca-standard.png -------------------------------------------------------------------------------- /scripts/getCasesHistory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import boto3 4 | import datetime 5 | import botocore 6 | import sys, getopt 7 | 8 | 9 | 10 | sup = boto3.client('support') 11 | org = boto3.client('organizations') 12 | sts = boto3.client('sts') 13 | ddb = boto3.resource('dynamodb') 14 | comprehend = boto3.client('comprehend') 15 | ddbTable = "COCA" 16 | Sentiments = {"POSITIVE": 3, "NEUTRAL": 2, "MIXED": 1, "NEGATIVE": 0} 17 | 18 | def get_accounts_list(org_client): 19 | po = org_client.get_paginator('list_accounts') 20 | pi = po.paginate().build_full_result() 21 | return [a['Id'] for a in pi['Accounts'] if a['Status'] == 'ACTIVE'] 22 | 23 | def get_support_cases_list(sup): 24 | ps = sup.get_paginator('describe_cases') 25 | # get cases created in the last 120 days 26 | date = datetime.datetime.now() - datetime.timedelta(days=120) 27 | try: 28 | pi = ps.paginate( 29 | includeResolvedCases=True, 30 | afterTime=date.strftime('%Y-%m-%d') 31 | ).build_full_result() 32 | # if account is not subscribed to premium supoprt, we get an exception 33 | except botocore.exceptions.ClientError as e: 34 | return False 35 | return pi['cases'] 36 | 37 | def get_credentials(account_id,role): 38 | role_arn = "arn:aws:iam::"+account_id+":role/"+role 39 | assumed_role = sts.assume_role( 40 | RoleArn = role_arn, 41 | RoleSessionName = "SupportKPIAssumeRole" 42 | ) 43 | return assumed_role['Credentials'] 44 | 45 | def get_my_payer(): 46 | return org.describe_organization()['Organization']['MasterAccountId'] 47 | 48 | def get_number_of_comms(comms): 49 | nbcomm_aws = 0 50 | nbcomm_cx = 0 51 | for c in comms['recentCommunications']['communications']: 52 | if c['submittedBy'] == "Amazon Web Services": 53 | nbcomm_aws += 1 54 | else: 55 | nbcomm_cx += 1 56 | return {'aws': nbcomm_aws, 'cx': nbcomm_cx} 57 | 58 | def get_last_update_info(caseDict): 59 | # gets timestamp and source from last communication 60 | # returns timestamp and source as string 61 | return True 62 | 63 | def get_sentiment_from_message(message, language): 64 | if len(str(message)) > 0 and len(str(message)) < 5000: 65 | r = comprehend.detect_sentiment(Text=str(message),LanguageCode=language) 66 | return r['Sentiment'] 67 | else: 68 | return 'NEUTRAL' 69 | 70 | def get_dominant_language(message): 71 | if len(str(message)) > 0 and len(str(message)) < 5000: 72 | r = comprehend.detect_dominant_language(Text=str(message)) 73 | return r['Languages'][0]['LanguageCode'] 74 | else: 75 | return 'en' 76 | 77 | def is_rto_met(caseDict): 78 | rto_times = { 79 | 'low': 1440, 80 | 'normal': 720, 81 | 'high': 240, 82 | 'urgent': 60, 83 | 'critical': 15 84 | } 85 | rto = rto_times[caseDict['severityCode']] 86 | a = caseDict['timeCreated'] 87 | b = caseDict['recentCommunications']['communications'] 88 | for i in range(-1,-len(b),-1): 89 | if b[i]['submittedBy'] == "Amazon Web Services": 90 | da = datetime.datetime.strptime(a,'%Y-%m-%dT%H:%M:%S.%fZ') 91 | db = datetime.datetime.strptime(b[i]['timeCreated'],'%Y-%m-%dT%H:%M:%S.%fZ') 92 | diff = db - da 93 | if divmod(diff.total_seconds(),60)[0] > rto: 94 | return False 95 | return True 96 | 97 | def get_last_cx_comm(caseDict): 98 | comms = caseDict['recentCommunications']['communications'] 99 | for i in range(0,len(comms),1): 100 | if comms[i]['submittedBy'] != "Amazon Web Services": 101 | return comms[i]['body'] 102 | return -1 103 | 104 | 105 | def insert_case_in_ddb(caseDict,account): 106 | comms = get_number_of_comms(caseDict) 107 | rto = is_rto_met(caseDict) 108 | table = ddb.Table(ddbTable) 109 | message = get_last_cx_comm(caseDict) 110 | language = get_dominant_language(message) 111 | sentiment = get_sentiment_from_message(message, language) 112 | caseItem={ 113 | 'caseId': caseDict['caseId'], 114 | 'awsAccount': account, 115 | 'displayId': caseDict['displayId'], 116 | 'subject': caseDict['subject'], 117 | 'caseStatus': caseDict['status'], 118 | 'serviceCode': caseDict['serviceCode'], 119 | 'categoryCode': caseDict['categoryCode'], 120 | 'severityCode': caseDict['severityCode'], 121 | 'submittedBy': caseDict["recentCommunications"]["communications"][-1]["submittedBy"], 122 | 'timeCreated': caseDict['timeCreated'], 123 | 'timeLastUpdated': caseDict["recentCommunications"]["communications"][0]["timeCreated"], 124 | 'lastUpdatedBy': caseDict["recentCommunications"]["communications"][0]["submittedBy"], 125 | 'nbReopens': 0, 126 | 'sentiment': Sentiments[sentiment], 127 | 'sentimentTrend': -1, 128 | 'nbAWSComms': comms['aws'], 129 | 'nbCustomerComms': comms['cx'], 130 | 'caseLanguage': '', 131 | 'rtoMet': rto, 132 | } 133 | if caseDict['status'] == 'resolved': 134 | caseItem['timeLastResolved'] = caseDict["recentCommunications"]["communications"][0]["timeCreated"] 135 | table.put_item(Item=caseItem) 136 | 137 | 138 | def usage(): 139 | name = sys.argv[0] 140 | print(f"usage: {name} [-h] [-d n] [-p] [-r] [-o]") 141 | print("-h: this help") 142 | print("-d/--days n: import cases created in the last days") 143 | print("-p/--payers: comma separated list of payers") 144 | print("-r/--assume-role: name of the role to assume in each account for DescribeCases calls (defaults to COCAAssumeRole)") 145 | print("-o/--account-map-role: name of the role to assume in the payer account(s) to describe the Organizations (defaults to COCAAccountMapRole)") 146 | sys.exit() 147 | 148 | def main(argv): 149 | payers = [] 150 | accounts = [] 151 | role = "COCAAssumeRole" 152 | orole = "COCAAccountMapRole" 153 | days = 120 154 | opts, args = getopt.getopt(argv,"hp:d:r:", ["payers","days","assume-role"]) 155 | for opt, arg in opts: 156 | if opt == "-h": 157 | usage() 158 | elif opt in ("-p", "--payers"): 159 | payers = arg.split(',') 160 | elif opt in ("-d", "--days"): 161 | days = arg 162 | elif opt in ("-r", "--assume-role"): 163 | role = arg 164 | elif opt in ("-o", "--account-map-role"): 165 | orole = arg 166 | payers.append(get_my_payer()) 167 | print(f"Listing accounts in {len(payers)} AWS Organizations") 168 | for p in payers: 169 | creds = get_credentials(p, orole) 170 | org = boto3.client('organizations', 171 | aws_access_key_id=creds['AccessKeyId'], 172 | aws_secret_access_key=creds['SecretAccessKey'], 173 | aws_session_token=creds['SessionToken'] 174 | ) 175 | accounts += get_accounts_list(org) 176 | print(f"We found {len(accounts)} accounts, proceeding to import cases") 177 | runningAccount = sts.get_caller_identity()['Account'] 178 | 179 | print("Importing all support cases created in the last {} days, please be patient".format(days)) 180 | for account in accounts: 181 | print("working with account {}".format(account)) 182 | if account == runningAccount: 183 | sup = boto3.client('support') 184 | else: 185 | creds = get_credentials(account,role) 186 | sup = boto3.client('support', 187 | aws_access_key_id=creds['AccessKeyId'], 188 | aws_secret_access_key=creds['SecretAccessKey'], 189 | aws_session_token=creds['SessionToken'] 190 | ) 191 | cases_list = get_support_cases_list(sup) 192 | if cases_list: 193 | print("-- importing {} cases for account {}".format(len(cases_list),account)) 194 | for caseDict in cases_list: 195 | insert_case_in_ddb(caseDict,account) 196 | 197 | else: 198 | print('{} does not have any support cases, or no premium support subscription'.format(account)) 199 | 200 | 201 | 202 | if __name__ == "__main__": 203 | main(sys.argv[1:]) 204 | --------------------------------------------------------------------------------