All Articles

Setting up an AWS organization from scratch with Terraform

When setting up a new organization on AWS, you have to create quite a few resources before you can develop any application infrastructure. Thankfully these resources, just like almost any other resources, can be created and managed with Terraform.

Architect drawing

In this article, we’re going to create:

  • AWS organization with three accounts:

    • users
    • staging
    • production
  • IAM groups and roles for developers,
  • S3 bucket & DynamoDB table for Terraform backend.

All of the code that’s in this post is available on GitHub https://github.com/tbekas/aws-organization-example.

AWS organization

First, we’re going to define an empty AWS organization.

provider "aws" {
    region = "eu-central-1"
}

resource "aws_organizations_organization" "organization" {
}

Next, we’re going to create three accounts within the organization. We’re going to use the users account only for user management while the staging and production we’re going to use for the application infrastructure, therefore we’re going to have an environment separation on the level of AWS accounts.

resource "aws_organizations_account" "users" {
  name      = "acme-corp-users"
  email     = "admin+users@acmecorp.com"
  role_name = "Admin"
}

resource "aws_organizations_account" "staging" {
  name      = "acme-corp-staging"
  email     = "admin+staging@acmecorp.com"
  role_name = "Admin"
}

resource "aws_organizations_account" "production" {
  name      = "acme-corp-production"
  email     = "admin+production@acmecorp.com"
  role_name = "Admin"
}

Note that there’s a different email for each account. That is because, for any given email address, there can be at most one AWS account associated with it.

Next, we’re going to define three aliased providers that we’re going to use throughout the rest of the article. We are no longer going to use the default (unaliased) AWS provider, which is associated with the root account.

provider "aws" {
  assume_role {
    role_arn = "arn:aws:iam::${aws_organizations_account.users.id}:role/Admin"
  }

  alias  = "users"
  region = "eu-central-1"
}

provider "aws" {
  assume_role {
    role_arn = "arn:aws:iam::${aws_organizations_account.staging.id}:role/Admin"
  }

  alias  = "staging"
  region = "eu-central-1"
}

provider "aws" {
  assume_role {
    role_arn = "arn:aws:iam::${aws_organizations_account.production.id}:role/Admin"
  }

  alias  = "production"
  region = "eu-central-1"
}

IAM groups and roles for developers

As noted previously, we’re going to use users account only for user management. To do that effectively, we’re going to need some IAM groups, roles, and policies. We’re going to focus on the developers only. However, you might find other groups necessary, like accountants who have access to AWS account billing only.

User group for self-management security credentials

First, we’re going to create the IAM group with policies attached for allowing users the self-management of security credentials, like changing the password and MFA secrets. The goal is to have users being able to fully onboard themselves.

resource "aws_iam_group" "self_managing" {
  name = "SelfManaging"

  provider = aws.users
}

resource "aws_iam_group_policy_attachment" "iam_read_only_access" {
  group      = aws_iam_group.self_managing.name
  policy_arn = "arn:aws:iam::aws:policy/IAMReadOnlyAccess"

  provider = aws.users
}

resource "aws_iam_group_policy_attachment" "iam_self_manage_service_specific_credentials" {
  group      = aws_iam_group.self_managing.name
  policy_arn = "arn:aws:iam::aws:policy/IAMSelfManageServiceSpecificCredentials"

  provider = aws.users
}

resource "aws_iam_group_policy_attachment" "iam_user_change_password" {
  group      = aws_iam_group.self_managing.name
  policy_arn = "arn:aws:iam::aws:policy/IAMUserChangePassword"

  provider = aws.users
}

resource "aws_iam_policy" "self_manage_vmfa" {
  name   = "SelfManageVMFA"
  policy = file("${path.module}/data/self_manage_vmfa.json")

  provider = aws.users
}

resource "aws_iam_group_policy_attachment" "self_manage_vmfa" {
  group      = aws_iam_group.self_managing.name
  policy_arn = aws_iam_policy.self_manage_vmfa.arn

  provider = aws.users
}

As you see in the code, we are using some of the pre-existing IAM policies adding one newly defined: SelfManageVMFA. The content of this policy is listed below.

// data/self_manage_vmfa.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "iam:CreateVirtualMFADevice",
        "iam:EnableMFADevice",
        "iam:ResyncMFADevice",
        "iam:DeleteVirtualMFADevice"
      ],
      "Resource": [
        "arn:aws:iam::*:mfa/${aws:username}",
        "arn:aws:iam::*:user/${aws:username}"
      ]
    },
    {
      "Sid": "AllowUsersToDeactivateTheirOwnVirtualMFADevice",
      "Effect": "Allow",
      "Action": [
        "iam:DeactivateMFADevice"
      ],
      "Resource": [
        "arn:aws:iam::*:mfa/${aws:username}",
        "arn:aws:iam::*:user/${aws:username}"
      ],
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": [
        "iam:ListMFADevices",
        "iam:ListVirtualMFADevices",
        "iam:ListUsers"
      ],
      "Resource": "*"
    }
  ]
}

User groups for accessing staging and production

So far, we’ve allowed users just to login and change their security credentials. Now we’re going to define resources for developers like administrative access to staging and production accounts.

Since the resources created below are nearly identical for staging and production, with the only difference being in parameters, we’re going to abstract them into two modules:

  • developer-role which we’re going to use for creating roles in staging and production accounts,
  • developer-group which we’re going to use for creating initially empty groups in the users account, members of this group are going to be allowed to assume roles created by the first module.

Developer role module

# modules/developer-role/main.tf

variable "trusted_entity" {
  type = string
}

resource "aws_iam_role" "this" {
  name = "Developer"

  assume_role_policy = data.template_file.trust_relationship.rendered
}

data "template_file" "trust_relationship" {
  template = <<TEMPLATE
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "${trusted_entity}"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        }
      }
    }
  ]
}
TEMPLATE

  vars = {
    trusted_entity = var.trusted_entity
  }
}

resource "aws_iam_role_policy_attachment" "administrator_access" {
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
  role       = aws_iam_role.this.name
}

output "role_arn" {
  value = aws_iam_role.this.arn
}

Note that there are no explicitly specified providers here. We’re going to see why that is later on.

Developer group module

# modules/developer-group/main.tf

variable "group_name" {
  type = string
}

variable "assume_role_arns" {
  type = list(string)
}

resource "aws_iam_group" "this" {
  name = var.group_name
}

resource "aws_iam_policy" "assume_role" {
  name   = "${var.group_name}AssumeRole"
  policy = data.template_file.assume_role.rendered
}

data "template_file" "assume_role" {
  template = <<TEMPLATE
{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Action": "sts:AssumeRole",
    "Resource": ${roles_arns_json}
  }
}
TEMPLATE

  vars = {
    roles_arns_json = jsonencode(var.assume_role_arns)
  }
}

resource "aws_iam_group_policy_attachment" "assume_role" {
  group      = aws_iam_group.this.name
  policy_arn = aws_iam_policy.assume_role.arn
}

output "group_name" {
  value = aws_iam_group.this.name
}

Instantiating the modules

module "developer_role_staging" {
  source         = "modules/developer-role"
  trusted_entity = "arn:aws:iam::${aws_organizations_account.users.id}:root"

  providers = {
    aws = aws.staging
  }
}

module "developer_role_production" {
  source         = "modules/developer-role"
  trusted_entity = "arn:aws:iam::${aws_organizations_account.users.id}:root"

  providers = {
    aws = aws.production
  }
}

module "developer_group_staging" {
  source     = "modules/developer-group"
  group_name = "DevelopersStaging"

  assume_role_arns = [
    module.developer_role_staging.role_arn,
  ]

  providers = {
    aws = aws.users
  }
}

module "developer_group_production" {
  source     = "modules/developer-group"
  group_name = "DevelopersProduction"

  assume_role_arns = [
    module.developer_role_production.role_arn
  ]

  providers = {
    aws = aws.users
  }
}

Recall that we didn’t explicitly specify the provider for any resource inside of the modules. That was because we want to use different providers for different module instances, so we pass the provider as a “parameter” to the module.

IAM user account

User accounts are indifferent from any other resource so they can be managed with Terraform as well. Since we are likely going to create more than one user, it’s another opportunity to abstract all the necessary resources into a module.

# modules/user/main.tf

variable "name" {
  type = string
}

variable "pgp_key" {
  type = string
}

variable "groups" {
  type = list(string)
}

resource "aws_iam_user" "this" {
  name = var.name
}

resource "aws_iam_user_login_profile" "this" {
  user    = aws_iam_user.this.name
  pgp_key = var.pgp_key
}

resource "aws_iam_access_key" "this" {
  user    = aws_iam_user.this.name
  pgp_key = var.pgp_key
}

resource "aws_iam_user_group_membership" "this" {
  user = aws_iam_user.this.name

  groups = var.groups
}

output "summary" {
  value = {
    name              = var.name
    password          = aws_iam_user_login_profile.this.encrypted_password
    access_key_id     = aws_iam_access_key.this.id
    secret_access_key = aws_iam_access_key.this.encrypted_secret
  }
}

Adding a user

module "john_doe" {
  source  = "modules/user"
  name    = "john.doe"
  pgp_key = "mQINBF6OHB4BEADdALTRMOcfD..." # user public key

  groups = [
    aws_iam_group.self_managing.name,
    module.developer_group_staging.group_name,
    module.developer_group_production.group_name 
  ]

  providers = {
    aws = aws.users
  }
}

output "users_summary" {
  value = [
    module.john_doe.summary
  ]
}

One advantage of managing users in this way is less hassle from the management point of view. A developer can create a pull request for creating an IAM user and a group membership for himself and ask authorized people for approving his or her pull request, which, when merged, would trigger a CI job applying the plan and creating whatever was requested.

The PGP public key is necessary for encrypting user secrets, precisely a temporary password, and a secure access key. The detailed instruction on generating the PGP key pair and using it in the context of user creation can be found in a dedicated documentation page adding-a-user.md.

After a successful plan application, we should see the output similar to this:

users_summary = [
  {
    "access_key_id" = "AKIAWS5GSEV382CPXLTL"
    "name" = "john.doe"
    "password" = "wcFMAz6CNXRTPinJARAANmK8A..."
    "secret_access_key" = "wcFMAz6CNXRTPinJARAATwF2f..."
  },
]

Since the secrets are encrypted, only the PGP key pair owner can access them.

Terraform backend

A Terraform backend consists of a storage and a locking mechanism. One of the most popular backends is a combination of an S3 bucket for storage and a DynamoDB table for locking.

We’re going to need one backend per environment, so we are going to abstract all the necessary resources into a module.

# modules/backend/main.tf

variable "bucket_name" {
  type = string
}

variable "table_name" {
  type    = string
  default = "terraform-lock"
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  acl    = "private"

  versioning {
    enabled = true
  }

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_dynamodb_table" "this" {
  name         = var.table_name
  hash_key     = "LockID"
  billing_mode = "PAY_PER_REQUEST"

  attribute {
    name = "LockID"
    type = "S"
  }
}

The module has one mandatory parameter bucket_name, and that’s because an S3 bucket name has to be globally unique (rules for bucket naming).

module "backend_staging" {
  source      = "modules/backend"
  bucket_name = "acme-corp-terraform-state-staging"

  providers = {
    aws = aws.staging
  }
}

module "backend_production" {
  source      = "modules/backend"
  bucket_name = "acme-corp-terraform-state-production"

  providers = {
    aws = aws.production
  }
}

Now, when setting up an application infrastructure, we can use the configured backends in the following way:

# part of the application infrastracture codebase

terraform {
  backend "s3" {
    bucket         = "acme-corp-terraform-state-staging"
    region         = "eu-central-1"
    key            = "terraform.tfstate"
    dynamodb_table = "terraform-lock"
  }
}

Apply the Terraform plan

If you haven’t already, apply the changes. It might take a while, and it might fail for the first time, so you may want to try and re-run it. There’s likely some undocumented delay between creating the AWS account and being able to use it that may cause the initial failure.

Also, it’s worth mentioning that we should keep the application infrastructure code in a separate repository from the provisioning code we’ve just created. There are at least three two reasons for that:

  • setting up an AWS organization requires root account privileges which are unnecessary for managing the application infrastructure;
  • merging a pull request that possibly is granting someone access to staging or production environment should require a different set of permissions than merging a pull request with application infrastructure changes;
  • there is no shared code between the two codebases and no explicit dependencies (the implicit dependency is that the application infrastructure needs AWS accounts and Terraform backend to work).

Next steps

You should now have a fully functioning AWS organization and be ready to develop your infrastructure. If you don’t know where to start, check out the AWS Whitepapers.

Thank you for reading, if you found any issues or if you have some ideas for improvement, please let me know in the comments.