├── .gitignore ├── Dockerfile ├── LICENSE.TXT ├── README.md ├── content ├── welcome_email.html ├── welcome_email.txt └── welcome_email_cssinline.html ├── iamtool.py ├── run_tests.py ├── sample_email.html ├── src ├── iamutils.py ├── main.py ├── privnote.py └── provision.py ├── terraform ├── dynamodb.tf ├── iam.tf ├── provider.tf ├── remotestate.tf └── ses.tf └── tests └── iamutils_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | .vagrant/ 4 | .terraform/ 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | RUN pip install boto3 premailer pyyaml first 4 | 5 | # install nodejs and privnote-cli 6 | # privnote-cli npm install fails on node 8.10.0, works on 4.7.3 7 | RUN cd /usr/local && \ 8 | wget https://nodejs.org/dist/v4.7.3/node-v4.7.3-linux-x64.tar.xz && \ 9 | tar -C /usr/local --strip-components 1 -xf node-v4.7.3-linux-x64.tar.xz && \ 10 | node --version && \ 11 | npm install -g privnote-cli 12 | 13 | # install terraform 14 | RUN apt-get update -y && \ 15 | apt-get install -y wget unzip dos2unix && \ 16 | wget https://releases.hashicorp.com/terraform/0.11.5/terraform_0.11.5_linux_amd64.zip && \ 17 | unzip terraform_0.11.5_linux_amd64.zip -d /usr/local/bin/ && \ 18 | terraform --version 19 | 20 | ADD ./src /src 21 | ADD ./tests /tests 22 | ADD ./terraform /terraform 23 | ADD ./content /content 24 | 25 | # make sure the text isn't malformed if built on a windows host 26 | RUN apt-get update -y && \ 27 | apt-get install -y dos2unix && \ 28 | dos2unix /src/*.py 29 | 30 | WORKDIR /src 31 | 32 | # make python behave in docker 33 | ENV PYTHONUNBUFFERED=1 34 | ENV PYTHONIOENCODING=utf8 35 | 36 | # boto3 log level 37 | ENV LOGLEVEL=WARNING 38 | 39 | ENV IAMTOOL_DYNAMODB_CONFIG_TABLE_NAME=iamusertool_config 40 | ENV IAMTOOL_SES_TEMPLATE_NAME=iamtool_welcome 41 | 42 | ENTRYPOINT ["python3", "-u", "main.py"] 43 | -------------------------------------------------------------------------------- /LICENSE.TXT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Factor Systems Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS IAM User Tool 2 | 3 | A command line tool to create AWS IAM users, set permissions, and send a welcome email to the newly created user with all necessary login and usage information. 4 | 5 | See a [sample](http://htmlpreview.github.io/?https://github.com/billtrust/aws-iam-user-tool/blob/master/sample_email.html) of the welcome email this tool sends out when creating a new IAM user. 6 | 7 | ## Features 8 | 9 | * Create the user and assign to the specified IAM groups. 10 | * Encrypts the password via Privnote.com for secure distribution with self destruct after the password link is clicked. 11 | * Auto discovers your account's password policy and includes these details in the welcome email. 12 | * Auto discovers your cross account IAM role configuration and includes switch account URLs in the welcome email. 13 | * Auto discovers your account's login URL with alias and includes this in the welcome email. 14 | * Emails the user a welcome email with all necessary information to setup MFA and access their account. 15 | 16 | ## Build 17 | 18 | ``` 19 | docker build -t billtrust/aws-iam-user-tool:latest . 20 | ``` 21 | 22 | ## Setup 23 | 24 | ### Setup - One time Creation of AWS Resources and Setting Configuration 25 | 26 | To run the Terraform script to create the SES email template and other AWS resources, the below is required to run one as a pre-requisite. If the `/content/welcome_email.html` template changes, this will need to be run again. This will also run the premailer program to inline the css and create the `/content/welcome_email_cssinline.html` file. 27 | 28 | ``` 29 | docker run \ 30 | -e AWS_ACCESS_KEY_ID=<> \ 31 | -e AWS_SECRET_ACCESS_KEY=<> \ 32 | -e AWS_DEFAULT_REGION=us-east-1 \ 33 | --entrypoint python \ 34 | billtrust/aws-iam-user-tool:latest \ 35 | /src/provision.py \ 36 | --tfstate-bucket mycompany-tfstate \ 37 | --tfstate-dynamotable tfstate \ 38 | --email-from-address noreply@aws-dev.billtrust.com \ 39 | --email-replyto-address noreply@aws-dev.billtrust.com \ 40 | --email-bcc-address this-is-optional@mycompany.com 41 | ``` 42 | 43 | The AWS creds provided above must be sufficient to create/access all AWS resosurces required, including IAM, SES, S3, and DynamoDb (for Terraform state). See the "terraform" folder to see exactly which resources are created. Terraform is configured here to remotely store its tfstate files in S3. As such, the creds you supply additionally need to be able to read and write from the S3 bucket you designate via the `--tfstate-bucket` argument your administrative creds, and the DyanamoDB table you designate via the `--tfstate-dynamotable` argument. 44 | 45 | ### Setup - Terraform RemoteState Bucket 46 | 47 | If you have not yet setup remote state in Terraform, you'll just need to create the S3 bucket you want to use for state. Terraform will take care of writing to it and creating the DynamoDB lock table. This is the bucket name referred to in the above argument `--tfstate-bucket`. 48 | 49 | You can create a bucket however you like, including by the following AWS CLI command: 50 | 51 | ``` 52 | aws s3api create-bucket --bucket mycompany-terraform-tfstate --region us-east-1 --acl private 53 | ``` 54 | 55 | ### Setup - SES Domain Verification 56 | 57 | SES will not send emails until you have verified a domain for use with SES. If you have not already configured SES for use with your email, see the instructions below: 58 | 59 | Verifying a Domain With Amazon SES: 60 | https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-domain-procedure.html 61 | 62 | Or to verify just a single email address for testing: 63 | https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses-procedure.html 64 | 65 | ## Prerequisites 66 | 67 | You will need the following installed on your workstation to use this tool. 68 | 69 | * Docker 70 | * Python 71 | * Boto3 - `pip install boto3` if not already installed 72 | * A default AWS profile which has credentials to assume the role `bt-policy-aws-iam-user-tool`. If you would like to use a different profile, change the `--local-aws-profile` argument to specify the profile name. 73 | 74 | ## Usage 75 | 76 | To create a user execute `iamtool.py` passing arguments for the username, email, and IAM groups the user should belong to. 77 | 78 | Example: 79 | ```shell 80 | # make sure to build first, the image is not currently published to docker hub 81 | docker build -t billtrust/aws-iam-user-tool:latest . 82 | # iamtool.py generates AWS temp credentials and executes the tool within a container 83 | python ./iamtool.py \ 84 | create \ 85 | --user-name testuser \ 86 | --user-email testuser@mycompany.com \ 87 | --iam-group master-allusers \ 88 | --iam-group dev-developers \ 89 | --iam-group stage-developers \ 90 | --iam-group prod-developers \ 91 | --region us-east-1 \ 92 | --profile default 93 | ``` 94 | 95 | ## Run Tests 96 | 97 | To run unit tests simply execute `python run_tests.py` which will run the unit tests inside the container. 98 | 99 | ## License 100 | 101 | MIT License 102 | 103 | Copyright (c) 2018 Factor Systems Inc. 104 | 105 | Permission is hereby granted, free of charge, to any person obtaining a copy 106 | of this software and associated documentation files (the "Software"), to deal 107 | in the Software without restriction, including without limitation the rights 108 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 109 | copies of the Software, and to permit persons to whom the Software is 110 | furnished to do so, subject to the following conditions: 111 | 112 | The above copyright notice and this permission notice shall be included in all 113 | copies or substantial portions of the Software. 114 | 115 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 116 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 117 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 118 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 119 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 120 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 121 | SOFTWARE. 122 | -------------------------------------------------------------------------------- /content/welcome_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AWS master account login credentials. 7 | 8 | 9 | 10 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 221 | 222 | 223 |
32 |
33 | 34 | 35 | 38 | 39 | 40 | 203 | 204 |
36 | Your AWS master account login credentials. 37 |
41 | 42 | 43 | 46 | 47 | 48 | 52 | 53 | 54 | 58 | 59 | 60 | 65 | 66 | 67 | 85 | 86 | 87 | 98 | 99 | {{#if roles}} 100 | 101 | 104 | 105 | 106 | {{#each roles}} 107 | 108 | 113 | 114 | {{/each}} 115 | 116 | 117 | 121 | 122 | 123 | 198 | 199 | {{/if}} 200 | 201 |
44 | Here are your AWS master account login credentials. Do NOT delete this email, you may need to reference information contained here in the future. 45 |
49 | Login URL:
50 | {{aws_console_login_url}} 51 |
55 | Username:
56 | {{iam_username}} 57 |
61 | Temporary Password:
62 | Click the following link to reveal your password. The link will work only once.
63 | {{encrypted_pw_url}} 64 |
68 | You will be prompted to change your password upon login. Your password must have the following criteria:
69 |
    70 |
  • Minimum of {{pw_policy.MinimumPasswordLength}} characters
  • 71 | {{#if pw_policy.RequireSymbols}} 72 |
  • Must contain at least 1 special character
  • 73 | {{/if}} 74 | {{#if pw_policy.RequireNumbers}} 75 |
  • Must contain at least one number
  • 76 | {{/if}} 77 | {{#if pw_policy.RequireUppercaseCharacters}} 78 |
  • Must contain at least one upper case character
  • 79 | {{/if}} 80 | {{#if pw_policy.RequireLowercaseCharacters}} 81 |
  • Must contain at least one lower case character
  • 82 | {{/if}} 83 |
84 |
88 | You will need to setup MFA on your account. Without doing this you will not have access to do anything in AWS apart from configure MFA. To setup MFA, click the edit icon next to "Assigned MFA device" on the Security Credentials tab of your IAM user:
89 | https://console.aws.amazon.com/iam/home#/users/{{iam_username}}?section=security_credentials
90 |
91 | You will need to download a virtual authenticator such as Authy or Google Authenticator which are available in the iOS App Store or Google Play. Authy is the recommended choice.
92 |
93 | Note: After setting up MFA you may need to log out and log back into the AWS web console in order to change roles.
94 |
95 | For more information on how to setup MFA, see:
96 | http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html 97 |
102 | Your master account user has cross account roles which allow access to our AWS accounts for our other environments. Once you log into AWS with your master account credentials, you can then assume the roles you were given access to, in order to access the other AWS environments. Use the below links to access the other AWS environments. 103 |
109 | {{env_name}}
110 | Assume Role URL:
111 | {{assume_role_url}}
112 |
118 | Once you use the above links to initially assume the role, you can switch back and forth between roles in the account menu in the upper right hand corner of the console.
119 | 120 |
124 | To assume these roles with the command line or from code, you will need to setup access keys for your 125 | master account user and configure the AWS CLI for your terminal.
126 |
127 | Generate Access Keys 128 |
129 | To generate access keys for your AWS master account, first go to the Security Credential tab 130 | of your IAM user:
131 | https://console.aws.amazon.com/iam/home#/users/{{iam_username}}?section=security_credentials
132 |
133 | Click Create Access Key to generate an access and secret key.
134 |
135 | Install the AWS CLI
136 | If you have Python and Pip installed the easiest way will be from a command prompt:
137 |
138 | 										$ pip install awscli --upgrade --user
139 | 										
140 | For other installation options, see:
141 | https://docs.aws.amazon.com/cli/latest/userguide/installing.html
142 |
143 | Configuring your AWS Profile 144 |
145 |
146 | 										$ aws configure --profile {{iam_username}}
147 | 										
148 | Then follow the prompts to enter the below information.
149 |
150 |
151 | AWS Access Key ID [None]: (your access key from the earlier step)
152 | AWS Secret Access Key [None]: (your secret key from the earlier step)
153 | Default region name [None]: {{region}}
154 | Default output format [None]: json
155 | 
156 | Once you have configured your access and secret key for your master account with the AWS CLI, 157 | please do not store these keys anywhere else. You can always view the values again 158 | by viewing the file ~/.aws/credentials. Your master account credentials are used only to assume the 159 | cross account roles. To configure your cross account roles, open ~/.aws/config in 160 | a text editor and add the following section:
161 |
162 |
163 | {{#each roles}}[profile {{env_prefix}}]
164 | role_arn = {{role_arn}}
165 | source_profile = {{iam_username}}
166 | output = json
167 | region = {{region}}
168 | {{/each}}
169 | 
170 | Testing
171 | You can test your credentials by attempting to use the AWS CLI from your terminal.
172 |
173 |
174 | $ aws sts assume-role \
175 | 	--role-arn {{example_role_arn}} \
176 | 	--role-session-name testing \
177 | 	--profile {{iam_username}}
178 | 
179 | Successful output of that command will look something like the following, and indicates that you are able to assume the role with your master account user.
180 |
181 |
182 | {
183 |   "AssumedRoleUser": {
184 |     "AssumedRoleId": "AROASOMETHINGSOMETHING:testing", 
185 |     "Arn": "{{example_role_arn}}"
186 |   },
187 |   "Credentials": {
188 |     "SecretAccessKey": "blahcu43kl34blaHlhfdlsubaljucla", 
189 |     "SessionToken": "biglongstringhere=", 
190 |     "Expiration": "", 
191 |     "AccessKeyId": "ASIASOMETHINGSOMETHING"
192 |   }
193 | }
194 | 
195 | For additional information regarding this process, see:
196 | https://docs.aws.amazon.com/cli/latest/userguide/cli-roles.html 197 |
202 |
205 |
220 |
224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /content/welcome_email.txt: -------------------------------------------------------------------------------- 1 | Here are your AWS master account login credentials. Do NOT delete this email, you may need to reference information contained here in the future. 2 | 3 | Login URL: 4 | {{aws_console_login_url}} 5 | 6 | Username: 7 | {{iam_username}} 8 | 9 | Temporary Password: 10 | Click the following link to reveal your password. The link will work only once. 11 | {{encrypted_pw_url}} 12 | 13 | You will be prompted to change your password upon login. Your password must have the following criteria: 14 | Minimum of {{pw_policy.MinimumPasswordLength}} characters 15 | {{#if pw_policy.RequireSymbols}} 16 | Must contain at least 1 special character 17 | {{/if}} 18 | {{#if pw_policy.RequireNumbers}} 19 | Must contain at least one number 20 | {{/if}} 21 | {{#if pw_policy.RequireUppercaseCharacters}} 22 | Must contain at least one upper case character 23 | {{/if}} 24 | {{#if pw_policy.RequireLowercaseCharacters}} 25 | Must contain at least one lower case character 26 | {{/if}} 27 | 28 | You will need to setup MFA on your account. Without doing this you will not have access to do anything in AWS apart from configure MFA. To setup MFA, click the edit icon next to "Assigned MFA device" on the Security Credentials tab of your IAM user: 29 | https://console.aws.amazon.com/iam/home#/users/{{iam_username}}?section=security_credentials 30 | 31 | You will need to download a virtual authenticator such as Authy or Google Authenticator which are available in the iOS App Store or Google Play. Authy is the recommended choice. 32 | 33 | Note: After setting up MFA you may need to log out and log back into the AWS web console in order to change roles. 34 | 35 | For more information on how to setup MFA, see: 36 | http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html 37 | 38 | {{#if roles}} 39 | Your master account user has cross account roles which allow access to our AWS accounts for our other environments. Once you log into AWS with your master account credentials, you can then assume the roles you were given access to, in order to access the other AWS environments. Use the below links to access the other AWS environments. 40 | 41 | {{#each roles}} 42 | {{env_name}} 43 | Assume Role URL: 44 | {{assume_role_url}} 45 | {{/each}} 46 | 47 | To assume these roles with the command line or from code, you will need to setup access keys for your 48 | master account user and configure the AWS CLI for your terminal. 49 | 50 | Generate Access Keys 51 | 52 | To generate access keys for your AWS master account, first go to the Security Credential tab 53 | of your IAM user: 54 | https://console.aws.amazon.com/iam/home#/users/{{iam_username}}?section=security_credentials 55 | 56 | Click "Create Access Key" to generate an access and secret key. 57 | 58 | Install the AWS CLI 59 | 60 | If you have Python and Pip installed the easiest way will be from a command prompt: 61 | 62 | $ pip install awscli --upgrade --user 63 | 64 | For other installation options, see: 65 | https://docs.aws.amazon.com/cli/latest/userguide/installing.html 66 | 67 | Configuring your AWS Profile 68 | 69 | $ aws configure --profile {{iam_username}} 70 | 71 | Then follow the prompts to enter the below information. 72 | 73 | AWS Access Key ID [None]: (your access key from the earlier step) 74 | AWS Secret Access Key [None]: (your secret key from the earlier step) 75 | Default region name [None]: {{region}} 76 | Default output format [None]: json 77 | 78 | 79 | Once you have configured your access and secret key for your master account with the AWS CLI, 80 | please do not store these keys anywhere else. You can always view the values again 81 | by viewing the file ~/.aws/credentials. Your master account credentials are used only to assume the 82 | cross account roles. To configure your cross account roles, open ~/.aws/config in 83 | a text editor and add the following section: 84 | 85 | {{#each roles}} 86 | [profile {{env_prefix}}] 87 | role_arn = {{role_arn}} 88 | source_profile = {{iam_username}} 89 | output = json 90 | region = {{region}} 91 | {{/each}} 92 | 93 | Testing 94 | You can test your credentials by attempting to use the AWS CLI from your terminal. 95 | 96 | $ aws sts assume-role \ 97 | --role-arn {{example_role_arn}} \ 98 | --role-session-name testing \ 99 | --profile {{iam_username}} 100 | 101 | Successful output of that command will look something like the following, and indicates that you are able to assume the role with your master account user. 102 | 103 | { 104 | "AssumedRoleUser": { 105 | "AssumedRoleId": "AROASOMETHINGSOMETHING:testing", 106 | "Arn": "{{example_role_arn}}" 107 | }, 108 | "Credentials": { 109 | "SecretAccessKey": "blahcu43kl34blaHlhfdlsubaljucla", 110 | "SessionToken": "biglongstringhere=", 111 | "Expiration": "", 112 | "AccessKeyId": "ASIASOMETHINGSOMETHING" 113 | } 114 | } 115 | {{/if}} 116 | 117 | Generation of your IAM user and this email were automated, source code located here: 118 | https://github.com/billtrust/aws-iam-user-tool 119 | -------------------------------------------------------------------------------- /content/welcome_email_cssinline.html: -------------------------------------------------------------------------------- 1 | Initially blank, setup.sh to run premailer to perform css inlining and create this file. -------------------------------------------------------------------------------- /iamtool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from __future__ import print_function 3 | 4 | import os 5 | import sys 6 | import argparse 7 | import tempfile 8 | import subprocess 9 | import uuid 10 | import re 11 | import boto3 12 | from string import Template 13 | 14 | 15 | def get_aws_temp_creds(role_name, profile_name = None): 16 | if profile_name: 17 | session = boto3.Session(profile_name=profile_name) 18 | sts_client = session.client('sts') 19 | iam_client = session.client('iam') 20 | else: 21 | sts_client = boto3.client('sts') 22 | iam_client = boto3.client('iam') 23 | 24 | try: 25 | role_arn = iam_client.get_role(RoleName=role_name)['Role']['Arn'] 26 | except Exception as e: 27 | print("Error reading role arn for role name {}: {}".format(role_name, e)) 28 | raise 29 | 30 | try: 31 | random_session = uuid.uuid4().hex 32 | assumed_role_object = sts_client.assume_role( 33 | RoleArn=role_arn, 34 | RoleSessionName="docker-session-{}".format(random_session), 35 | DurationSeconds=3600 # 1 hour max 36 | ) 37 | access_key = assumed_role_object["Credentials"]["AccessKeyId"] 38 | secret_key = assumed_role_object["Credentials"]["SecretAccessKey"] 39 | session_token = assumed_role_object["Credentials"]["SessionToken"] 40 | except Exception as e: 41 | print("Error assuming role {}: {}".format(role_arn, e)) 42 | raise 43 | 44 | print("Generated temporary AWS credentials: {}".format(access_key)) 45 | return access_key, secret_key, session_token 46 | 47 | 48 | def exec_command(command): 49 | # print(command) 50 | output = "" 51 | try: 52 | p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) 53 | p_status = p.wait() 54 | (output, err) = p.communicate() 55 | output = output.decode("utf-8") 56 | # print (output) 57 | except Exception as e: 58 | print ("Error: Output: {} \nException:{}".format(output, str(e))) 59 | return 1, output, -1 60 | return p.returncode, output 61 | 62 | 63 | def single_line_string(string): 64 | # replace all runs of whitespace to a single space 65 | string = re.sub('\s+', ' ', string) 66 | # remove newlines 67 | string = string.replace('\n', '') 68 | return string 69 | 70 | 71 | def get_docker_inspect_exit_code(container_name): 72 | inspect_command = "docker inspect {} --format='{{{{.State.ExitCode}}}}'".format( 73 | container_name) 74 | (returncode, output) = exec_command(inspect_command) 75 | if not returncode == 0: 76 | print("Error from docker (docker exit code {}) inspect trying to get container exit code, output: {}".format(returncode, output)) 77 | sys.exit(1) 78 | 79 | try: 80 | container_exit_code = int(output.replace("'", "")) 81 | except Exception as e: 82 | print("Error parsing exit code from docker inspect, raw output: {}".format(output)) 83 | sys.exit(1) 84 | 85 | # pass along the exit code from the container 86 | print("Container exited with code {}".format(container_exit_code)) 87 | return container_exit_code 88 | 89 | 90 | def remove_docker_container(container_name): 91 | print("Removing container: {}".format(container_name)) 92 | remove_command = "docker rm {}".format(container_name) 93 | (returncode, output) = exec_command(remove_command) 94 | if not returncode == 0: 95 | print("Error removing named container! Run 'docker container prune' to cleanup manually.") 96 | 97 | 98 | def random_container_name(): 99 | return uuid.uuid4().hex 100 | 101 | 102 | def generate_temp_env_file( 103 | access_key, 104 | secret_key, 105 | session_token, 106 | region): 107 | envs = [] 108 | envs.append("AWS_ACCESS_KEY_ID=" + access_key) 109 | envs.append("AWS_SECRET_ACCESS_KEY=" + secret_key) 110 | envs.append("AWS_SESSION_TOKEN=" + session_token) 111 | envs.append("AWS_DEFAULT_REGION=" + region) 112 | envs.append("AWS_REGION=" + region) 113 | envs.append("PYTHONUNBUFFERED=1") 114 | if os.environ.get('APP_LOGLEVEL', None): 115 | envs.append("APP_LOGLEVEL=" + os.environ['APP_LOGLEVEL']) 116 | 117 | temp_env_file = tempfile.NamedTemporaryFile(delete=False, mode="w") 118 | for item in envs: 119 | temp_env_file.write("%s\n" % item) 120 | temp_env_file.close() 121 | print("Temp envs file: {}".format(temp_env_file.name)) 122 | return temp_env_file.name 123 | 124 | if __name__ == "__main__": 125 | 126 | parser = argparse.ArgumentParser() 127 | parser.add_argument("command", nargs='*', 128 | choices=["create"], 129 | help="The command to be executed") 130 | parser.add_argument("--user-name", required=True) 131 | parser.add_argument("--user-email", required=True) 132 | parser.add_argument("--iam-group", required=False, action="append") 133 | parser.add_argument("--region", default="us-east-1") 134 | parser.add_argument("--profile", 135 | help="The AWS creds used on your laptop to generate the STS temp credentials") 136 | 137 | try: 138 | args = parser.parse_args() 139 | except argparse.ArgumentError as exc: 140 | print(exc.message, '\n', exc.argument) 141 | 142 | access_key, secret_key, session_token = \ 143 | get_aws_temp_creds("role-aws-iam-user-tool", args.profile) 144 | 145 | env_tmpfile = generate_temp_env_file( 146 | access_key, 147 | secret_key, 148 | session_token, 149 | args.region 150 | ) 151 | 152 | if 'create' in args.command: 153 | cmd = "create --user-name {} --user-email {}".format( 154 | args.user_name, args.user_email) 155 | for group in args.iam_group: 156 | cmd += " --iam-group {}".format(group) 157 | 158 | container_name = random_container_name() 159 | command = Template(single_line_string(""" 160 | docker run 161 | --name $container_name 162 | --env-file $env_tmpfile 163 | billtrust/aws-iam-user-tool:latest 164 | $cmd 165 | """)) \ 166 | .substitute({ 167 | 'env_tmpfile': env_tmpfile, 168 | 'container_name': container_name, 169 | 'cmd': cmd 170 | }) 171 | 172 | print(command) 173 | os.system(command) 174 | 175 | exit_code = get_docker_inspect_exit_code(container_name) 176 | remove_docker_container(container_name) 177 | os.remove(env_tmpfile) 178 | 179 | sys.exit(exit_code) 180 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # 4 | # run the python unit tests inside docker with the app's IAM role 5 | # 6 | 7 | import os 8 | import sys 9 | import argparse 10 | import tempfile 11 | import subprocess 12 | import uuid 13 | import re 14 | import boto3 15 | from string import Template 16 | 17 | 18 | def get_aws_temp_creds(role_name, local_aws_profile=None): 19 | if local_aws_profile: 20 | session = boto3.Session(profile_name=args.local_aws_profile) 21 | sts_client = session.client('sts') 22 | else: 23 | sts_client = boto3.client('sts') 24 | 25 | try: 26 | iam = boto3.client('iam') 27 | role_arn = iam.get_role(RoleName=role_name)['Role']['Arn'] 28 | except Exception as e: 29 | print("Error reading role arn for role name {}: {}".format(role_arn, e)) 30 | raise 31 | 32 | try: 33 | random_session = uuid.uuid4().hex 34 | assumedRoleObject = sts_client.assume_role( 35 | RoleArn=role_arn, 36 | RoleSessionName="docker-session-{}".format(random_session), 37 | DurationSeconds=3600 # 1 hour max 38 | ) 39 | access_key = assumedRoleObject["Credentials"]["AccessKeyId"] 40 | secret_key = assumedRoleObject["Credentials"]["SecretAccessKey"] 41 | session_token = assumedRoleObject["Credentials"]["SessionToken"] 42 | except Exception as e: 43 | print("Error assuming role {}: {}".format(role_arn, e)) 44 | raise 45 | 46 | print("Generated temporary AWS credentials: {}".format(access_key)) 47 | return access_key, secret_key, session_token 48 | 49 | 50 | def exec_command(command): 51 | # print(command) 52 | output = "" 53 | try: 54 | p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) 55 | p_status = p.wait() 56 | (output, err) = p.communicate() 57 | output = output.decode("utf-8") 58 | # print (output) 59 | except Exception as e: 60 | print ("Error: Output: {} \nException:{}".format(output, str(e))) 61 | return (1, output, -1) 62 | return (p.returncode, output) 63 | 64 | 65 | def single_line_string(string): 66 | # replace all runs of whitespace to a single space 67 | string = re.sub('\s+', ' ', string) 68 | # remove newlines 69 | string = string.replace('\n', '') 70 | return string 71 | 72 | 73 | def get_docker_inspect_exit_code(container_name): 74 | inspect_command = "docker inspect {} --format='{{{{.State.ExitCode}}}}'".format( 75 | container_name) 76 | (returncode, output) = exec_command(inspect_command) 77 | if not returncode == 0: 78 | print("Error from docker (docker exit code {}) inspect trying to get container exit code, output: {}".format(returncode, output)) 79 | sys.exit(1) 80 | 81 | try: 82 | container_exit_code = int(output.replace("'", "")) 83 | except Exception as e: 84 | print("Error parsing exit code from docker inspect, raw output: {}".format(output)) 85 | sys.exit(1) 86 | 87 | # pass along the exit code from the container 88 | print("Container exited with code {}".format(container_exit_code)) 89 | return container_exit_code 90 | 91 | 92 | def remove_docker_container(container_name): 93 | print("Removing container: {}".format(container_name)) 94 | remove_command = "docker rm {}".format(container_name) 95 | (returncode, output) = exec_command(remove_command) 96 | if not returncode == 0: 97 | print("Error removing named container! Run 'docker container prune' to cleanup manually.") 98 | 99 | 100 | def random_container_name(): 101 | return uuid.uuid4().hex 102 | 103 | 104 | def generate_temp_env_file( 105 | access_key, 106 | secret_key, 107 | session_token, 108 | region): 109 | envs = [] 110 | envs.append("AWS_ACCESS_KEY_ID=" + access_key) 111 | envs.append("AWS_SECRET_ACCESS_KEY=" + secret_key) 112 | envs.append("AWS_SESSION_TOKEN=" + session_token) 113 | envs.append("AWS_DEFAULT_REGION=" + region) 114 | envs.append("AWS_REGION=" + region) 115 | envs.append("PYTHONUNBUFFERED=1") 116 | 117 | temp_env_file = tempfile.NamedTemporaryFile(delete=False, mode="w") 118 | for item in envs: 119 | temp_env_file.write("%s\n" % item) 120 | temp_env_file.close() 121 | print("Temp envs file: {}".format(temp_env_file.name)) 122 | return temp_env_file.name 123 | 124 | if __name__ == "__main__": 125 | 126 | parser = argparse.ArgumentParser() 127 | parser.add_argument("--region", default="us-east-1") 128 | parser.add_argument("--local-aws-profile", 129 | help="The AWS creds used on your laptop to generate the STS temp credentials") 130 | parser.add_argument("--no-volume", action="store_true", default=False) 131 | 132 | try: 133 | args = parser.parse_args() 134 | except argparse.ArgumentError as exc: 135 | print(exc.message, '\n', exc.argument) 136 | 137 | access_key, secret_key, session_token = \ 138 | get_aws_temp_creds("role-aws-iam-user-tool", args.local_aws_profile) 139 | 140 | env_tmpfile = generate_temp_env_file( 141 | access_key, 142 | secret_key, 143 | session_token, 144 | args.region 145 | ) 146 | 147 | cwd = os.getcwd() 148 | # if windows fix paths 149 | if os.name == 'nt': 150 | cwd = cwd.replace('\\', '/') 151 | volumemount = '-v {}/src:/src '.format(cwd) 152 | volumemount += '-v {}/tests:/tests '.format(cwd) 153 | container_name = random_container_name() 154 | command = Template(single_line_string(""" 155 | docker run 156 | --name $container_name 157 | --env-file $env_tmpfile 158 | $volumemount 159 | -w / 160 | --entrypoint python 161 | billtrust/aws-iam-user-tool:latest 162 | -m unittest tests.iamutils_test.TestIamUtils 163 | """)) \ 164 | .substitute({ 165 | 'env_tmpfile': env_tmpfile, 166 | 'container_name': container_name, 167 | 'volumemount': '' if args.no_volume else volumemount, 168 | }) 169 | 170 | print (command) 171 | os.system(command) 172 | 173 | exit_code = get_docker_inspect_exit_code(container_name) 174 | remove_docker_container(container_name) 175 | os.remove(env_tmpfile) 176 | 177 | sys.exit(exit_code) 178 | -------------------------------------------------------------------------------- /sample_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AWS master account login credentials. 7 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 174 | 175 | 176 |
38 |
39 | 40 | 41 | 44 | 45 | 46 | 156 | 157 |
42 | Your AWS master account login credentials. 43 |
47 | 48 | 49 | 52 | 53 | 54 | 58 | 59 | 60 | 64 | 65 | 66 | 73 | 74 | 75 | 93 | 94 | 95 | 106 | 107 | 108 | 109 | 112 | 113 | 114 | 115 | 116 | 121 | 122 | 123 | 124 | 129 | 130 | 131 | 132 | 137 | 138 | 139 | 140 | 141 | 145 | 146 | 147 | 151 | 152 | 153 | 154 |
50 | Here are your AWS master account login credentials. 51 |
55 | Login URL:
56 | https://your-company-alias.signin.aws.amazon.com/console 57 |
61 | Username:
62 | test-user 63 |
67 | Temporary Password:
68 | Click the following link to reveal your password. The link will work only once.
69 | https://privnote.com/aR1uksUq#1nXDspsKr 70 | 71 | 72 |
76 | You will be prompted to change your password upon login. Your password must have the following criteria:
77 |
    78 |
  • Minimum of 8 characters
  • 79 | 80 |
  • Must contain at least 1 special character
  • 81 | 82 | 83 |
  • Must contain at least one number
  • 84 | 85 | 86 |
  • Must contain at least one upper case character
  • 87 | 88 | 89 |
  • Must contain at least one lower case character
  • 90 | 91 |
92 |
96 | You will need to setup MFA on your account. Without doing this you will not have access to do anything in AWS apart from configure MFA. To setup MFA, click the edit icon next to "Assigned MFA device" on the Security Credentials tab of your IAM user:
97 | https://console.aws.amazon.com/iam/home#/users/test-user?section=security_credentials
98 |
99 | You will need to download a virtual authenticator such as Authy or Google Authenticator which are available in the iOS App Store or Google Play. Authy is the recommended choice.
100 |
101 | Note: After setting up MFA you may need to log out and log back into the AWS web console in order to change roles. 102 |
103 | For more information on how to setup MFA, see:
104 | http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html 105 |
110 | Your master account user has cross account roles which allow access to our AWS accounts for our other environments. Once you log into AWS with your master account credentials, you can then assume the roles you were given access to, in order to access the other AWS environments. Use the below links to access the other AWS environments. 111 |
117 | Dev
118 | Assume Role URL:
119 | https://signin.aws.amazon.com/switchrole?account=1234567890123&roleName=role-ops-developers&displayName=dev-developers
120 |
125 | Stage
126 | Assume Role URL:
127 | https://signin.aws.amazon.com/switchrole?account=2234567890124&roleName=role-ops-developers&displayName=stage-developers
128 |
133 | Prod
134 | Assume Role URL:
135 | https://signin.aws.amazon.com/switchrole?account=3234567890125&roleName=role-ops-developers&displayName=prod-developers
136 |
142 | Once you use the above links to initially assume the role, you can switch back and forth between roles in the account menu in the upper right hand corner of the console.
143 | 144 |
148 | To assume these roles with the command line or from code, follow these instructions:
149 | https://docs.aws.amazon.com/cli/latest/userguide/cli-roles.html 150 |
155 |
158 |
173 |
177 | 178 | 179 | -------------------------------------------------------------------------------- /src/iamutils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | import hashlib 4 | import json 5 | import boto3 6 | import privnote 7 | from botocore.exceptions import ClientError 8 | from boto3 import resource 9 | 10 | import logging 11 | logging.basicConfig(level=os.environ.get("APP_LOGLEVEL", "INFO")) 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def get_aws_account_id(): 16 | return boto3.client('sts').get_caller_identity()['Account'] 17 | 18 | def ensure_envvars(): 19 | """Ensure that these environment variables are provided at runtime""" 20 | required_envvars = [ 21 | "AWS_DEFAULT_REGION", 22 | "IAMTOOL_DYNAMODB_CONFIG_TABLE_NAME", 23 | "IAMTOOL_SES_TEMPLATE_NAME" 24 | ] 25 | 26 | missing_envvars = [] 27 | for required_envvar in required_envvars: 28 | if not os.environ.get(required_envvar, ''): 29 | missing_envvars.append(required_envvar) 30 | 31 | if missing_envvars: 32 | message = "Required environment variables are missing: " + \ 33 | repr(missing_envvars) 34 | logger.error(message) 35 | raise AssertionError(message) 36 | 37 | 38 | def user_exists(user_name): 39 | client = boto3.client('iam') 40 | try: 41 | client.get_user(UserName=user_name) 42 | except ClientError as e: 43 | if e.response['Error']['Code'] == 'NoSuchEntity': 44 | return False 45 | else: 46 | logger.error("Unexpected error from IAM get_user:", e) 47 | raise 48 | except Exception as e: 49 | logger.error("Unexpected error from IAM get_user:", e) 50 | raise 51 | return True 52 | 53 | 54 | def provision_user(user_name, group_names, user_email, ses_email_template_name): 55 | email_config = get_email_config() 56 | validate_iam_group_names(group_names) 57 | 58 | # create user and assign groups 59 | temppw = generate_temp_password(16) 60 | privnote_url = privnote.generate_privnote_url(temppw) 61 | create_iam_user(user_name, temppw) 62 | add_user_to_groups(user_name, group_names) 63 | 64 | # get metadata for email 65 | password_policy = get_password_policy() 66 | cross_account_groups = get_cross_account_role_groupings(group_names) 67 | console_login_url = get_console_login_url() 68 | aws_account_id = get_aws_account_id() 69 | # not perfect, but region is not part of iam resources so there is no way 70 | # to know the default region of an IAM role or account. best assumption 71 | # to make is to use the current region iam user tool is running from. 72 | region = os.environ['AWS_DEFAULT_REGION'] 73 | # because the email template needs a single variable (can't seem to {{role[0]}}) 74 | example_role_arn = cross_account_groups[0]['role_arn'] 75 | props = generate_email_template_data( 76 | user_name, 77 | privnote_url, 78 | password_policy, 79 | cross_account_groups, 80 | console_login_url, 81 | aws_account_id, 82 | region, 83 | example_role_arn) 84 | 85 | # send the email 86 | send_ses_templated_email( 87 | ses_template_name=ses_email_template_name, 88 | email_to=user_email, 89 | email_bcc=email_config['email_bcc_address'] if 'email_bcc_address' in email_config else None, 90 | email_from=email_config['email_from_address'], 91 | email_replyto=email_config['email_replyto_address'], 92 | template_data=json.dumps(props) 93 | ) 94 | 95 | 96 | def set_email_config(email_from, email_replyto, email_bcc=None, id='EMAIL_CONFIG'): 97 | item = { 98 | 'id': id, 99 | 'email_from_address': email_from, 100 | 'email_replyto_address': email_replyto, 101 | } 102 | if email_bcc: 103 | item['email_bcc_address'] = email_bcc 104 | 105 | logger.info("Setting email configuration to DynamoDB...") 106 | boto3.setup_default_session(region_name=os.environ['AWS_DEFAULT_REGION']) 107 | dynamodb_resource = resource('dynamodb') 108 | table = dynamodb_resource.Table(os.environ['IAMTOOL_DYNAMODB_CONFIG_TABLE_NAME']) 109 | _ = table.put_item(Item=item) 110 | 111 | 112 | def get_email_config(id='EMAIL_CONFIG'): 113 | logger.info("Retrieving email configuration from DynamoDB...") 114 | boto3.setup_default_session(region_name=os.environ['AWS_DEFAULT_REGION']) 115 | dynamodb_resource = resource('dynamodb') 116 | table_name = os.environ['IAMTOOL_DYNAMODB_CONFIG_TABLE_NAME'] 117 | table = dynamodb_resource.Table(table_name) 118 | response = table.get_item(Key={'id': id}) 119 | if not 'Item' in response: 120 | raise Exception(f"Could not read config from DynamoDB table {table_name}, id(key): {id}") 121 | return response['Item'] 122 | 123 | 124 | def generate_email_template_data( 125 | user_name, privnote_url, password_policy, cross_account_groups, console_login_url, 126 | aws_account_id, region, example_role_arn): 127 | props = { 128 | "aws_console_login_url": console_login_url, 129 | "iam_username": user_name, 130 | "encrypted_pw_url": privnote_url, 131 | "pw_policy": { 132 | "MinimumPasswordLength": password_policy['MinimumPasswordLength'], 133 | "RequireSymbols": password_policy['RequireSymbols'], 134 | "RequireNumbers": password_policy['RequireNumbers'], 135 | "RequireUppercaseCharacters": password_policy['RequireUppercaseCharacters'], 136 | "RequireLowercaseCharacters": password_policy['RequireLowercaseCharacters'] 137 | }, 138 | "roles": cross_account_groups, 139 | "aws_account_id": aws_account_id, 140 | "region": region, 141 | "example_role_arn": example_role_arn 142 | } 143 | logger.debug("Email template data: " + str(props)) 144 | return props 145 | 146 | 147 | def validate_iam_group_names(group_names): 148 | """Since the IAM group names are passed into the CLI as 149 | arguments, this will validate them to ensure that these 150 | group names exist and were typed correctly.""" 151 | client = boto3.client('iam') 152 | for group_name in group_names: 153 | logger.info(f"Validating IAM group {group_name}") 154 | try: 155 | response = client.get_group( 156 | GroupName=group_name 157 | ) 158 | except Exception as e: 159 | message = f"Invalid IAM Group specified: {group_name}" 160 | logger.error(message) 161 | raise AssertionError(message) 162 | 163 | 164 | def add_user_to_groups(user_name, group_names): 165 | client = boto3.client('iam') 166 | for group_name in group_names: 167 | logger.info(f"Adding {user_name} to group {group_name}") 168 | response = client.add_user_to_group( 169 | GroupName=group_name, 170 | UserName=user_name, 171 | ) 172 | 173 | 174 | def generate_temp_password(num_characters): 175 | pw = base64.urlsafe_b64encode( 176 | hashlib.md5(os.urandom(128)).digest() 177 | )[:num_characters] 178 | # make sure it meets the account password policy 179 | return f"A{pw}9!" 180 | 181 | 182 | def create_iam_user(user_name, temppw): 183 | client = boto3.client('iam') 184 | logger.info(f"Creating user {user_name}") 185 | response = client.create_user( 186 | Path='/', 187 | UserName=user_name 188 | ) 189 | logger.info(f"Creating login profile for user {user_name}") 190 | response = client.create_login_profile( 191 | UserName=user_name, 192 | Password=str(temppw), 193 | PasswordResetRequired=True 194 | ) 195 | 196 | 197 | def get_iam_group_arn(group_name): 198 | """Get a full iam group arn from just the group name""" 199 | client = boto3.client('iam') 200 | return client.get_group(GroupName=group_name)['Group']['Arn'] 201 | 202 | 203 | def get_account_id_from_arn(arn): 204 | if not type(arn) is str: 205 | raise Exception("Non string passed to get_account_id_from_arn") 206 | return arn.split(':')[4] 207 | 208 | 209 | def build_switch_role_url(assume_role_arn, display_name): 210 | role_name = assume_role_arn.split(':')[5].replace('role/', '') 211 | url = "https://signin.aws.amazon.com/switchrole" 212 | url += f"?account={get_account_id_from_arn(assume_role_arn)}" 213 | url += f"&roleName={role_name}" 214 | url += f"&displayName={display_name}" 215 | return url 216 | 217 | 218 | def get_group_attached_policy_arns(group_name): 219 | """Get all policy arns which are attached to the given group""" 220 | policy_arns = [] 221 | client = boto3.client('iam') 222 | response = client.list_attached_group_policies( 223 | GroupName=group_name, 224 | MaxItems=100 225 | ) 226 | for policy in response['AttachedPolicies']: 227 | policy_arns.append(policy['PolicyArn']) 228 | while response['IsTruncated'] == True: 229 | marker = response['Marker'] 230 | response = client.list_attached_group_policies( 231 | GroupName=group_name, 232 | MaxItems=100, 233 | Marker=marker 234 | ) 235 | for policy in response['AttachedPolicies']: 236 | policy_arns.append(policy['PolicyArn']) 237 | return policy_arns 238 | 239 | 240 | def get_active_policy_document(policy_arn): 241 | """Returns the policy (Dict) for the given policy arn""" 242 | client = boto3.client('iam') 243 | default_version_id = \ 244 | client.get_policy(PolicyArn=policy_arn)['Policy']['DefaultVersionId'] 245 | policy_document = client.get_policy_version( 246 | PolicyArn = policy_arn, 247 | VersionId = default_version_id 248 | )['PolicyVersion']['Document'] 249 | return policy_document 250 | 251 | 252 | def is_arn_iamrole(arn): 253 | """Given an arn, determines if this is an IAM role arn""" 254 | if not arn.startswith('arn:aws:iam'): 255 | logger.debug("is not an arn: {}".format(arn)) 256 | return False 257 | if not arn.split(':')[-1].startswith('role/'): 258 | return False 259 | return True 260 | 261 | 262 | def find_allowed_assume_role_arns(policy_arn): 263 | """Given an IAM policy, this digs into it to find all 264 | arns which are granted sts:AssumeRole permission. 265 | Returns a list of arns.""" 266 | policy_document = get_active_policy_document(policy_arn) 267 | logger.debug("policy_document:", policy_document) 268 | assume_role_arns = [] 269 | for statement in policy_document['Statement']: 270 | if isinstance(statement['Resource'], list): 271 | arns = statement['Resource'] 272 | else: 273 | arns = [statement['Resource']] 274 | for arn in arns: 275 | if is_arn_iamrole(arn) and \ 276 | statement['Effect'] == 'Allow' and \ 277 | 'sts:AssumeRole' in statement['Action']: 278 | assume_role_arns.append(arn) 279 | return assume_role_arns if not len(assume_role_arns) == 0 else None 280 | 281 | 282 | def get_iam_group_cross_account_role_arns(group_name): 283 | """Given an IAM group name, this will find any cross account IAM roles 284 | which can be assumed due to membership in this group""" 285 | 286 | # get the account id of the current account 287 | group_arn = get_iam_group_arn(group_name) 288 | group_account_id = get_account_id_from_arn(group_arn) 289 | 290 | # get all the role arn's this group is allowed to assume 291 | assume_role_arns = [] 292 | # get all policies attached to the group 293 | policy_arns = get_group_attached_policy_arns(group_name) 294 | logger.debug("policy_arns:", policy_arns) 295 | # get all roles allowed to be assumed by these policies 296 | for policy_arn in policy_arns: 297 | logger.debug("policy_arn:", policy_arn) 298 | arns = find_allowed_assume_role_arns(policy_arn) 299 | logger.debug("arns:", arns) 300 | if arns: assume_role_arns.extend(arns) 301 | 302 | # then build a list of roles which are cross account 303 | cross_account_role_arns = [] 304 | logger.debug(assume_role_arns) 305 | for assume_role_arn in assume_role_arns: 306 | role_account_id = get_account_id_from_arn(assume_role_arn) 307 | # if the role is in a different account, must be a cross role account 308 | if role_account_id != group_account_id: 309 | cross_account_role_arns.append(assume_role_arn) 310 | return cross_account_role_arns 311 | 312 | 313 | def get_cross_account_role_groupings(iam_group_names): 314 | """Return a Dict with all the cross role urls withenvironment name.""" 315 | display_name = get_account_alias() 316 | cross_account_groups = [] 317 | for group_name in iam_group_names: 318 | arns = get_iam_group_cross_account_role_arns(group_name) 319 | for arn in arns: 320 | # if there are multiple arns in the group, use the role name 321 | # else use the group name for the display name 322 | if len(arns) > 1: 323 | role_name = arn.split(':')[-1].split('/')[-1] 324 | display_name = role_name 325 | else: 326 | display_name = group_name 327 | cross_account_groups.append({ 328 | 'assume_role_url': build_switch_role_url(arn, display_name), 329 | 'role_arn': arn, 330 | 'region': os.environ['AWS_DEFAULT_REGION'], 331 | 'env_name': group_name, 332 | 'env_prefix': group_name.split('-')[0] 333 | } 334 | ) 335 | return cross_account_groups 336 | 337 | 338 | def get_password_policy(): 339 | logger.info("Retrieving account password policy") 340 | client = boto3.client('iam') 341 | response = client.get_account_password_policy() 342 | policy = response['PasswordPolicy'] 343 | return policy 344 | 345 | 346 | def get_account_alias(): 347 | iam = boto3.client('iam') 348 | 349 | paginator = iam.get_paginator('list_account_aliases') 350 | try: 351 | from first import first 352 | item = first(paginator.paginate(), default=None) 353 | if not item: 354 | raise Exception("No AWS account alias exists") 355 | else: 356 | if len(item['AccountAliases']) > 1: 357 | logger.warn("Multiple AWS account aliases found") 358 | return item['AccountAliases'][0] 359 | except Exception as e: 360 | raise Exception("Error retrieving AWS account alias: {}".format(e)) 361 | 362 | 363 | def get_console_login_url(): 364 | return "https://{}.signin.aws.amazon.com/console".format( 365 | get_account_alias() 366 | ) 367 | 368 | 369 | def send_ses_templated_email( 370 | ses_template_name, 371 | email_to, 372 | email_bcc, 373 | email_from, 374 | email_replyto, 375 | template_data 376 | ): 377 | logger.info(f"Sending {ses_template_name} email to {email_to}") 378 | aws_account_id = get_aws_account_id() 379 | region = os.environ["AWS_DEFAULT_REGION"] 380 | destinations = { 381 | 'ToAddresses': [ email_to ] 382 | } 383 | if email_bcc: 384 | destinations['BccAddresses'] = [ email_bcc ] 385 | 386 | client = boto3.client('ses') 387 | response = client.send_templated_email( 388 | Source=email_from, 389 | Destination=destinations, 390 | ReplyToAddresses=[ email_replyto ], 391 | ReturnPath=email_from, 392 | Template=ses_template_name, 393 | TemplateArn=f"arn:aws:ses:{region}:{aws_account_id}:template/{ses_template_name}", 394 | TemplateData=template_data 395 | ) 396 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | import argparse 5 | import iamutils 6 | 7 | if __name__ == "__main__": 8 | 9 | parser = argparse.ArgumentParser() 10 | parser.add_argument("command", nargs='*', 11 | choices=["create"], 12 | help="The command to be executed") 13 | parser.add_argument("--user-name", required=True) 14 | parser.add_argument("--user-email", required=True) 15 | parser.add_argument("--iam-group", action="append") 16 | 17 | try: 18 | args = parser.parse_args() 19 | except argparse.ArgumentError as exc: 20 | print(exc.message, '\n', exc.argument) 21 | 22 | # throws an exception if required environment variables aren't present 23 | # they are defined in the Dockerfile 24 | iamutils.ensure_envvars() 25 | 26 | if 'create' in args.command: 27 | if iamutils.user_exists(args.user_name): 28 | print("IAM User {} already exists!".format(args.user_name)) 29 | sys.exit(1) 30 | iamutils.provision_user( 31 | user_name=args.user_name, 32 | group_names=args.iam_group, 33 | user_email=args.user_email, 34 | ses_email_template_name=os.environ.get( 35 | "IAMTOOL_SES_TEMPLATE_NAME", "iamtool_welcome") 36 | ) 37 | else: 38 | parser.print_help() 39 | -------------------------------------------------------------------------------- /src/privnote.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import tempfile 4 | import base64 5 | import hashlib 6 | 7 | import logging 8 | logging.basicConfig(level=os.environ.get("APP_LOGLEVEL", "INFO")) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | # generate a random temp dir filename but don't create or open it 13 | def generate_random_tempfilename(): 14 | rand = base64.urlsafe_b64encode( 15 | hashlib.md5(os.urandom(128)).digest() 16 | )[:16] 17 | return os.path.join(tempfile.gettempdir(), '.{}'.format(hash(os.times()))) 18 | 19 | 20 | # relies on privnote-cli and node.js being installed in the Dockerfile 21 | # https://github.com/nonrational/privnote-cli 22 | def generate_privnote_url(note): 23 | logger.info("Generating privnote") 24 | # write the privnote out to disk 25 | temp_note_file = tempfile.NamedTemporaryFile(delete=False, mode="w") 26 | temp_note_file.write(str(note)) 27 | temp_note_file.close() 28 | 29 | # execute it 30 | privnote_filename = generate_random_tempfilename() 31 | os.system(f"privnote < {temp_note_file.name} > {privnote_filename}") 32 | 33 | # read the privnote from disk 34 | with open(privnote_filename, "r") as privnote_file: 35 | privnote_link = privnote_file.read() 36 | 37 | # cleanup 38 | os.remove(temp_note_file.name) 39 | os.remove(privnote_filename) 40 | 41 | logger.info(f"Generated privnote url: {privnote_link}") 42 | return privnote_link 43 | -------------------------------------------------------------------------------- /src/provision.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # this script intended to be run inside docker 4 | import os 5 | import argparse 6 | import iamutils 7 | 8 | if __name__ == "__main__": 9 | 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument("--region", required=False) 12 | parser.add_argument("--tfstate-bucket", required=True, 13 | help="The S3 bucket name containing your Terraform state files. Can be any private S3 bucket.") 14 | parser.add_argument("--tfstate-dynamotable", required=True, 15 | help="The DynamoDB table name which contains the Terraform state locks. Can be any table " + 16 | "name if you have not setup Terraform state previously.") 17 | parser.add_argument("--email-from-address", required=True, 18 | help="When the email is sent to the new user, it will come from this address.") 19 | parser.add_argument("--email-replyto-address", required=True, 20 | help="When the email is sent to the new user, it will use this as the replyto address.") 21 | parser.add_argument("--email-bcc-address", required=False, 22 | help="This is an optional bcc address for the email that is sent to the new user.") 23 | 24 | try: 25 | args = parser.parse_args() 26 | except argparse.ArgumentError as exc: 27 | print(exc.message, '\n', exc.argument) 28 | 29 | # if region argument absent, try to fill it from the environment 30 | if not args.region: 31 | args.region = os.environ.get("AWS_DEFAULT_REGION", None) 32 | if not args.region: 33 | raise Exception("AWS Region not specified!") 34 | 35 | print(""" 36 | *** 37 | NOTE: If you have updated your email template be sure to rebuild the 38 | docker container prior to running this setup script so any changes 39 | to /content are included. 40 | *** 41 | """) 42 | 43 | # run css inliner 44 | os.system(""" 45 | python -m premailer \ 46 | -f /content/welcome_email.html \ 47 | -o /content/welcome_email_cssinline.html 48 | """) 49 | 50 | # apply the ses template with terraform 51 | os.chdir("/terraform") 52 | os.system(f""" 53 | terraform init \ 54 | -backend-config="region={args.region}" \ 55 | -backend-config="bucket={args.tfstate_bucket}" \ 56 | -backend-config="dynamodb_table={args.tfstate_dynamotable}" 57 | """) 58 | os.system("terraform apply -auto-approve") 59 | 60 | iamutils.set_email_config( 61 | email_from=args.email_from_address, 62 | email_replyto=args.email_replyto_address, 63 | email_bcc=args.email_bcc_address if args.email_bcc_address else None 64 | ) 65 | -------------------------------------------------------------------------------- /terraform/dynamodb.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "iamusertool_config" { 2 | name = "iamusertool_config" 3 | read_capacity = 1 4 | write_capacity = 1 5 | hash_key = "id" 6 | 7 | attribute { 8 | name = "id" 9 | type = "S" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/iam.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "current" {} 2 | 3 | resource "aws_iam_role" "aws-iam-user-tool" { 4 | name = "role-aws-iam-user-tool" 5 | 6 | assume_role_policy = <>> print(policy_document) 180 | # {'Version': '2012-10-17', 'Statement': [{'Effect': 'Allow', 'Action': '*', 'Resource': '*'}]} 181 | self.assertEqual("Allow", 182 | policy_document['Statement'][0]['Effect']) 183 | self.assertEqual("*", 184 | policy_document['Statement'][0]['Resource']) 185 | 186 | def test_get_group_attached_policy_arns(self): 187 | policy_arns = iamutils.get_group_attached_policy_arns( 188 | self.test_iam_group_name) 189 | self.assertTrue( 190 | 'arn:aws:iam::aws:policy/AmazonPollyReadOnlyAccess' in policy_arns) 191 | 192 | def test_is_arn_iamrole(self): 193 | # example URLs from https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html 194 | arns_not_role = [ 195 | "arn:aws:elasticbeanstalk:us-east-1:123456789012:environment/My App/MyEnvironment", 196 | "arn:aws:iam::123456789012:user/David", 197 | "arn:aws:rds:eu-west-1:123456789012:db:mysql-db", 198 | "arn:aws:cloudfront::123456789012:*" 199 | ] 200 | for arn in arns_not_role: 201 | self.assertFalse(iamutils.is_arn_iamrole(arn)) 202 | self.assertTrue( 203 | iamutils.is_arn_iamrole("arn:aws:iam::123456789012:role/myrole") 204 | ) 205 | 206 | def test_find_allowed_assume_role_arns(self): 207 | arns = iamutils.find_allowed_assume_role_arns( 208 | self.test_iam_policy_arn) 209 | self.assertTrue( 210 | "arn:aws:iam::123456789012:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" \ 211 | in arns 212 | ) 213 | 214 | def test_get_iam_group_cross_account_role_arns(self): 215 | cross_account_arns = iamutils.get_iam_group_cross_account_role_arns( 216 | self.test_iam_group_name) 217 | self.assertTrue( 218 | "arn:aws:iam::123456789012:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling" \ 219 | in cross_account_arns 220 | ) 221 | 222 | def test_user_exists(self): 223 | self.assertTrue(iamutils.user_exists(self.test_iam_user_name)) 224 | self.assertFalse(iamutils.user_exists("not_a_valid_username")) 225 | 226 | def test_email_config(self): 227 | iamutils.set_email_config( 228 | email_from='devops@mycompany.com', 229 | email_replyto='noreply@mycompany.com', 230 | email_bcc ='someinterestedparty@mycompany.com', 231 | id='EMAIL_CONFIG_UNITTEST' 232 | ) 233 | config = iamutils.get_email_config(id='EMAIL_CONFIG_UNITTEST') 234 | self.assertEqual(config['email_from_address'], 'devops@mycompany.com') 235 | self.assertEqual(config['email_replyto_address'], 'noreply@mycompany.com') 236 | self.assertEqual(config['email_bcc_address'], 'someinterestedparty@mycompany.com') 237 | --------------------------------------------------------------------------------