Bootstrapping an AWS Organization
The purpose of this document is to take you step by step through the process of bootstrapping an AWS Organization. This is only intended for cases where we are setting up the AWS account structure from scratch.
Naming Conventions
Before starting this process, decide on a naming convention for your
organization. For instance, if you are setting up a new organization
for "spacecats", you will be creating AWS account aliases like
spacecats-org-root
, spacecats-id
, spacecats-sandbox
, etc.
Create Organization Root Account
First, you will use the AWS console to create an "organization root"
account. The purpose of this account is to handle root-level service
control policies (SCPs)
and encompass the the organizational units (OUs) underneath. If you
need to know how to create an AWS account, please see
https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/.
You should set the alias for this account to your project's name and
the org-root
suffix, like spacecats-org-root
.
If this is an organization Truss will be paying for, use your Truss credit card to pay for this account (this may be changed later). For the address, use the Market Street mailing address from our homepage. For a phone number, have a Dialpad administrator create you a Dialpad number for this project and use that for the verification phone number. For the moment, you can choose the free level of support; we can always upgrade this later.
Save the login and password to the 1Password vault you set up for this project previously, and then be sure to add MFA to the root account using the same 1Password entry. This is imperative for security purposes.
In order for the billing group users (created in the id account) to be able to access billing information in this account, see Activating access to the Billing and Cost Management console. This needs to be done as the root user, so now is a good time.
Bootstrap Terraform for org-root Account
Following the pattern in https://github.com/trussworks/terraform-layout-example,
set up your infra Git repo that we created earlier. Name the directory for this
account after the account alias as usual. Then, cd
into this directory and
checkout the bootstrap repo like so:
git clone git@github.com:trussworks/terraform-aws-bootstrap.git bootstrap
Follow the instructions in the bootstrap README and do the following:
- set up /bin directory with aws-vault-wrapper and symlinks
- set up .envrc in the top level and run
direnv allow
- set up .envrc in the account directory and run
direnv allow
; note that this .envrc should set theAWS_PROFILE
environment variable to match the name of this account.
Now create a temporary IAM user in the org-root
account and give it
administrative privileges. Name this user something obvious, like
"myuser-setup" so you can clean it up easily later. Generate an AWS key
pair and add an MFA for the user. Add the access keys to aws-vault by
running aws-vault add $AWS_PROFILE
from within the account directory.
Once you've done that, you should be able to run the bootstrap script as
described in the README.
Terraforming the org-root Account
Once Terraform is bootstrapped in the org-root
account, you will need to
create an admin-global
directory in the account directory of the infra
repo. Your first step should be setting up the providers.tf
and
terraform.tf
files; see the examples in the terraform-layout-example
repo linked above.
Create org-root Admin Users
Once those are set up, you can begin the rest of the work. Your first
action should be to create administrative IAM users that will only be
used to manage the org-root account and bootstrap the subordinate accounts.
These should not be used for anything else. Suffix the user name with
.org-root
to make it clear what these are for. They will be the only
accounts that can manage the org-root account.
Example Terraform code (suggest using a users.tf
file):
locals {
admin_users = [
"myuser.org-root",
]
}
resource "aws_iam_user" "admins" {
for_each = toset(local.admin_users)
name = each.value
force_destroy = true
tags = {
Automation = "Terraform"
}
}
module "admins_group" {
source = "trussworks/iam-user-group/aws"
version = "1.0.2"
user_list = local.admin_users
group_name = "admins"
allowed_roles = ["admin"]
}
# This is a generic role assumption policy that enforces MFA.
data "aws_iam_policy_document" "role_assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]
# only allow folks in this account
principals {
type = "AWS"
identifiers = [data.aws_caller_identity.current.account_id]
}
# require MFA
condition {
test = "Bool"
variable = "aws:MultiFactorAuthPresent"
values = ["true"]
}
}
}
resource "aws_iam_role" "admin" {
name = "admin"
description = "Role for organization administrators"
assume_role_policy = data.aws_iam_policy_document.role_assume_role_policy.json
tags = {
Automation = "Terraform"
}
}
resource "aws_iam_role_policy_attachment" "admin_administrator_access" {
group = aws_iam_role.admin.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
Once you have applied the Terraform and have the users created, issue
security keys for this user and add them to aws-vault; change your
profile for this account to use these new credentials and delete the
temporary user from your aws-vault and the org-root
account. You
should not use the root account anymore unless there is some kind of
emergency.
Create AWS Organization
Now we can begin creating the AWS Organization itself. There are a number of standard components that we'll want to create using Terraform:
- the AWS organization itself
- the initial OU which contains everything except the
suspended
OU - a
suspended
OU which will contain accounts quarantined due to security concerns - a policy which denies use of all AWS resources we can tie to the
suspended
OU (which will override the default FullAWSAccess policy); this is a part of the Truss org-scp module - an
id
account (spacecats-id
in our example) which will contain all of the IAM users we'll be using to interact with this organization - an
infra
account where we can put specific infra-only resources; if you are running Atlantis, this is where you can put the Terraform for that deployment, or you might put the root DNS records for your project's Route53 domain, etc.
You can do all of this from the org-root
account via Terraform:
# Create the AWS Organization
resource "aws_organizations_organization" "main" {
aws_service_access_principals = [
"cloudtrail.amazonaws.com",
"config.amazonaws.com",
]
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
]
feature_set = "ALL"
}
# Organizational Units
# The main OU, where all the resources we actually use will live.
resource "aws_organizations_organizational_unit" "spacecats" {
name = "spacecats"
parent_id = aws_organizations_organization.main.roots.0.id
}
# A suspended OU, where we can quarantine accounts.
resource "aws_organizations_organizational_unit" "suspended" {
name = "suspended"
parent_id = aws_organizations_organization.main.roots.0.id
}
# Add the Truss SCPs module and turn on the "deny all access" SCP for the
# suspended account. Note that this module has many other useful SCPs --
# see the docs for more info.
module "org-scps" {
source = "trussworks/org-scp/aws"
version = "~> 1.4.0"
deny_all_access_target_ids = [aws_organizations_organizational_unit.suspended.id]
}
# Organization Accounts
resource "aws_organizations_account" "spacecats_id" {
name = "spacecats-id"
email = "spacecats-infra+aws-org-id@example.com"
parent_id = aws_organizations_organizational_unit.spacecats.id
# Allow IAM user access to billing for this account so that we can let
# project/delivery managers access billing info without a root account.
iam_user_access_to_billing = "ALLOW"
tags = {
Automation = "Terraform"
}
}
resource "aws_organizations_account" "spacecats_infra" {
name = "spacecats-infra"
email = "spacecats-infra+aws-org-infra@example.com"
parent_id = aws_organizations_organizational_unit.spacecats.id
iam_user_access_to_billing = "DENY"
tags = {
Automation = "Terraform"
}
}
# Outputs for Accounts -- so you get the account numbers for later use
# You can put these in a separate outputs.tf file if you prefer
output "aws_organizations_account_spacecats_id_id" {
description = "ID for the Spacecats id account"
value = aws_organizations_account.spacecats_id.id
}
output "aws_organizations_account_spacecats_infra_id" {
description = "ID for the Spacecats infra account"
value = aws_organizations_account.spacecats_infra.id
}
A Note on Terraforming Accounts
Creating accounts in this way keeps you from having to add billing info, verifying a phone number, or any of the other hoops you have to jump through when creating a new AWS account. However, it also means the root user has no password set, making it impossible to log in as root without going through the password recovery process.
Instead, you can use the OrganizationAccountAccessRole
to access these accounts from the org-root
account. Set up a new profile
in your .aws/config
for the new account that looks like this:
[profile spacecats-id]
source_profile=spacecats-org-root
role_arn=arn:aws:iam::<spacecats-id account number>:role/OrganizationAccountAccessRole
region=us-west-2
output=json
# If you have MFA turned on, you will also need to add the mfa_serial
# line from the source_profile.
You can then use this role to get access into these accounts to bootstrap them for Terraform. Once you have Terraform set up, you can create the resources necessary to either create an IAM user you can use or an IAM role you can assume to work with this account, at which point you can use the profile pattern described in the next section.
Setting Up the id Account Users and Role Assumption
Follow the same steps you took to bootstrap the org-root
account to
bootstrap your id
account. Once you've done that, you can set up the
users and groups that team members will use to access all other AWS
resources. Truss uses iam-user-group
and iam-cross-acct-dest
modules to do this; see terraform-layout-example
for how we use them.
In order to do this, in your id
account, define your users and then
use the module like so:
data "aws_caller_identity" "current" {}
locals {
spacecats_infra_account_id = <spacecats-infra account number>
infra_users = [
"myuser",
]
}
resource "aws_iam_user" "infra_users" {
for_each = toset(local.infra_users)
name = each.value
force_destroy = true
tags = {
Automation = "Terraform"
}
}
module "infra_group_role" {
source = "trussworks/iam-cross-acct-src/aws"
version = "1.0.0"
destination_account_ids = [
data.aws_caller_identity.current.account_id,
local.spacecats_infra_account_id
]
destination_group_role = "infra"
}
module "infra_group" {
source = "trussworks/iam-user-group/aws"
version = "1.0.2"
user_list = local.infra_users
allowed_roles = [module.infra_group_role.arn]
group_name = "infra"
}
resource "aws_iam_role_policy_attachment" "infra_local_policy_attachment" {
role = module.infra_group_role.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}
Using that code, we get an IAM user myuser
in the group infra
, which
has permission to assume the infra
role in the current account and the
spacecats-infra
account. We also attach a policy to the infra
role we
created that grants power user access.
All user creation and grouping should be done in the id
account. In the
the other accounts, we grant these groups permission to assume roles that
grant whatever permissions they need. So in the spacecats-infra
account,
if we want to allow the infra
group to have similar permissions, we can
use this code in that account's namespace:
locals {
spacecats_id_acct_id = <spacecats-id account number>
module "infra_role" {
source = "trussworks/iam-cross-acct-dest/aws"
version = "1.0.0"
iam_role_name = "infra"
source_account_id = local.spacecats_id_account_id
}
resource "aws_iam_role_policy_attachment" "infra_local_policy_attachment" {
role = module.infra_role.iam_role_name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}
This code creates a new infra
role in the spacecats-infra
account and
allows users in the spacecats-id
account to assume that role in this
account as well. Note that we're not gating this based on the role in the
id
account; instead, we assume the id
account will do that gating for
us. Then we grant the infra
role the same level of permissions those
users had in the id
account. Note that we could give them a different
level of access if we wanted to -- general engineers will not have
permission to do much of anything in the id
account most of the time,
but we could grant them power user access in a sandbox account, and
perhaps only access to look at Cloudwatch logs in the staging or prod
accounts. Using this method allows you to do all your user management in
one place, and then give role-based access everywhere else.
In order for these users to access other accounts, you'll need to set up their profiles for these other accounts like so:
[profile spacecats-infra]
source_profile = spacecats-id
role_arn = arn:aws:iam::<spacecats-infra account number>:role/infra
region = us-west-2
output = json
# If you have MFA turned on, you will also need to add the mfa_serial
# line from the source_profile.
Next Steps
Once you have the org-root
, id
, and infra
accounts created, you
can move on to setting up the other parts of your infrastructure. If you
want to create more accounts, follow the example with the infra
account
above; you should refrain from creating any IAM users in non-id
accounts
if possible (though sometimes this is necessary for CI/CD or other
automation).
You should also set up some basic AWS services for the organization. Both
Cloudtrail and GuardDuty can be set up at the organization level in the
org-root
account, and Config should be set up in each account.
Finally, you should review the Truss org-scp
module
README and consider
implementing some of the standard SCPs to parts of your organization,
such as denying root account access and preventing accounts from removing
themselves from the organization.
Shared Resources
In general, with an organization, there should be a bias towards making each individual account as self-sufficient as possible and avoid using too many shared resources. One of the driving reasons for using an AWS Organization is the ability to compartmentalize resources; engineers can have wide permissions in a sandbox account, for instance, because they are not going to be able to touch anything in the main release pipeline. If you use a shared resource, like an S3 bucket, you end up having to do more detailed permission management to keep accounts from stepping on each other -- this essentially eliminates the advantage of the organization in the first place.
For most things, creating a separate resource for each account is likely the better play -- for things like S3 buckets, having two S3 buckets with half as much stuff as a single larger S3 bucket ends up essentially being the same cost. Even for things where the added cost is marginal, like VPCs or ECS services, the added peace of mind of splitting these services is usually worth it. In addition, separating resources like this makes it easy to spin up a complete new environment by simply stamping a new self-contained copy of all the necessary resources.
If you must have some shared resources, these should be served from an
account that is separate from the others -- the infra
account is a
likely place to put many of these, and that's why it is part of the
pattern, but it may not be appropriate for everything. In addition, you
will need to implement strong controls around access to these resources.