web text

AWS Three-Tier VPC network with Terraform

A three-tier network is an enterprise architecture to deliver the best performance and security to the end-users. Each component of the design is separated into tiers. Reminder, a typical three-tier network consists of a website then the application then the database from an end-user perspective. Not every website automatically works like that. The developers and engineers have to build the web application by separating the user interface from the logic and data. An AWS three-tier VPC network is not too difficult to build in the cloud, either. However, in this post, I’ll be using Terraform and Terragrunt to build and deploy an AWS three-tier VPC network using, of course, VPC, subnets, route tables, network access control lists (NACLs), and few other VPC parts. Next, I’ll share with you how to create the AWS application load balancer (ALB) and the target groups with health checks in another post.

The design

This will be a simple start and in future posts, I’ll add more details. This AWS three-tier VPC network module will create a VPC, subnets, Network Access Control Lists (NACLs), Internet Gateway, NAT Gateways, route tables, Elastic IPs, and few other resources using Terraform and I’ll deploy it with Terragrunt.

Notice there are two NAT gateways, this provides high availability and fault tolerance. This means if the NAT Gateway in the availability zone (AZ) A fails or gets corrupted then the EC2 instances in AZ B will still be able to function as expected. It’s a little bit more costly… it all depends on your requirements.

The Terraform Module

This module will be generic so I can reuse the three-tier VPC network over and over again. By creating a module it makes my main code a lot less, therefore it will be a lot cleaner to view and understand.

What’s Terraform and Terragrunt? Well visit my Intro to Terragrunt and Terraform post first then come back here! You can name your module anything you like… I named my three-tier-vpc. Reminder in Terraform we can use one or more .tf files to build a module. I’ll be separating this module into few different Terraform files just for organizational purposes. Here’s my structure.

aws/tf-modules/three-tier-vpc
               ├── README.md
               ├── gateways.tf
               ├── main.tf
               ├── nacls.tf
               ├── outputs.tf
               ├── routes.tf
               ├── sec-grps.tf
               └── vars.tf

AWS VPC

Let’s start with the main.tf file which contains the VPC resource. The VPC CIDR will be a variable so we can plugin any CIDR during deployment.

# Create the VPC
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  instance_tenancy     = "default"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(
    var.additional_tags,
    {
      Name = "${var.vpc_name}-vpc"
    }
  )
}

In my module I’m giving the vpc_cidr a default value but you’re not required to do so. This default won’t hurt because if this network range is already taken then terraform apply will fail cleanly.

# vars.tf
variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

The public subnet CIDR blocks will be variable too. Never hardcode values in modules that could or should be changed. The public_subnet_cidrs is a list of one or many CIDR blocks. If we provide one string value in the block then it will create one public subnet. If we provide four CIDR blocks then it will create four, how sweet is that!

# Create the public subnets
resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = "${var.aws_region}${var.zones[count.index]}"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.vpc_name}-public-subnet-${var.zones[count.index]}"
  }
}

The map_public_ip_on_launch attribute set to true is one of the configurations that makes this a public subnet. Just because we named it public doesn’t make it public. Next are the private subnets. Note the map_public_ip_on_launch is now set to false.

# Create the web subnets
resource "aws_subnet" "web" {
  count = length(var.web_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.web_subnet_cidrs[count.index]
  availability_zone       = "${var.aws_region}${var.zones[count.index]}"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.vpc_name}-web-subnet-${var.zones[count.index]}"
  }
}

The rest of the main.tf file contains the resources for the data and the app subnets, these subnets are non-public too.

VPC Gateways

In this three-tier VPC network architecture, we have only a few public subnets (meaning the instances launched in the public subnets will get Public IPs; which makes them routable without a NAT gateway). The public subnets will need an internet gateway to complete the design. The private or non-public subnets will need a NAT gateway to allow instances with just private IPs to communicate to the internet (AKA outbound internet access). NAT gateways need 1) a public IP, 2) added to your public subnet for each availability zone you are using!

# Add internet gateway
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${var.vpc_name}-internet-gateway"
  }
}

# Charges may occur

# Reserve EIPs
resource "aws_eip" "nat_a" {
  vpc = true

  tags = {
    Name = "${var.vpc_name}-eip-nat-a"
  }

}

# NAT Gateway in AZ A
resource "aws_nat_gateway" "zone_a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.vpc_name}-nat-gateway-aza"
  }

  depends_on = [
    aws_subnet.public
  ]
}

# Reserve EIPs
resource "aws_eip" "nat_b" {
  vpc = true

  tags = {
    Name = "${var.vpc_name}-eip-nat-b"
  }

}

# NAT Gateway in AZ B
resource "aws_nat_gateway" "zone_b" {
  allocation_id = aws_eip.nat_b.id
  subnet_id     = aws_subnet.public[1].id

  tags = {
    Name = "${var.vpc_name}-nat-gateway-azb"
  }

  depends_on = [
    aws_subnet.public
  ]
}

Note one of the comments in the code, EIP’s and the gateways may cost you for the duration of their existence.

VPC Routes

Every time a VPC is created the main route table is automatically provisioned, too. I’m going to tag it and create my own route tables for this module. Do note I do have some hardcoded values in this module. I have decided this will be for two AZ’s and no more so I hardcoded the index value of zero and one in the resources below.

# Tag the main route table
resource "aws_ec2_tag" "main_route_table" {
  resource_id = aws_vpc.this.main_route_table_id
  key         = "Name"
  value       = "${var.vpc_name}-main-route-table"
}

# Create route table for the public subnets
# Uses IG
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = {
    Name = "${var.vpc_name}-public-route-table"
  }

  depends_on = [
    aws_internet_gateway.this
  ]
}

# Associate the public subnets with the public route table
resource "aws_route_table_association" "public" {
  count = length(var.public_subnet_cidrs)

  subnet_id      = element(aws_subnet.public.*.id, count.index)
  route_table_id = aws_route_table.public.id
}

# Create a route table for the web and app subnets in AZ A
# Uses NAT gateway in AZ A
resource "aws_route_table" "private_aza" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.zone_a.id
  }

  tags = {
    Name = "${var.vpc_name}-private-route-table-aza"
  }

  depends_on = [
    aws_nat_gateway.zone_a
  ]
}

# Create a route table for the web and app subnets in AZ B
# Uses NAT gateway in AZ B
resource "aws_route_table" "private_azb" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.zone_b.id
  }

  tags = {
    Name = "${var.vpc_name}-private-route-table-azb"
  }

  depends_on = [
    aws_nat_gateway.zone_b
  ]
}

resource "aws_route_table" "data" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${var.vpc_name}-data-route-table"
  }
}

# Associate these subnets with the private route tables accordingly 
resource "aws_route_table_association" "web_aza" {
  subnet_id      = aws_subnet.web[0].id
  route_table_id = aws_route_table.private_aza.id
}

resource "aws_route_table_association" "app_aza" {
  subnet_id      = aws_subnet.app[0].id
  route_table_id = aws_route_table.private_aza.id
}

resource "aws_route_table_association" "web_azb" {
  subnet_id      = aws_subnet.web[1].id
  route_table_id = aws_route_table.private_azb.id
}

resource "aws_route_table_association" "app_azb" {
  subnet_id      = aws_subnet.app[1].id
  route_table_id = aws_route_table.private_azb.id
}

resource "aws_route_table_association" "data" {
  count = length(var.data_subnet_cidrs)

  subnet_id      = element(aws_subnet.data.*.id, count.index)
  route_table_id = aws_route_table.data.id
}

The routes are another set of configurations that differentiates public and internal sub-network. In the public subnet route table, all non-local traffic will be sent to the internet gateway. A private route table sends its non-local traffic to the NAT gateway which then routes it to the internet gateway and back.

VPC NACLs

Now it’s time to control which traffic is allowed or denied to this network. You may need to modify these rules to meet your requirements. For most web application projects, the public NACLs will most likely look like this.

# Public NACLS
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.this.id
  subnet_ids = [aws_subnet.public[0].id, aws_subnet.public[1].id]

  # Ingress rules
  # Allow all local traffic
  ingress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = aws_vpc.this.cidr_block
    from_port  = 0
    to_port    = 0
  }

  # Allow HTTPS traffic from the internet
  ingress {
    protocol   = "6"
    rule_no    = 105
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 443
    to_port    = 443
  }

  # Allow HTTP traffic from the internet
  ingress {
    protocol   = "6"
    rule_no    = 110
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  # Allow the ephemeral ports from the internet
  ingress {
    protocol   = "6"
    rule_no    = 120
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  ingress {
    protocol   = "17"
    rule_no    = 125
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  # Egress rules
  # Allow all ports, protocols, and IPs outbound
  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "${var.vpc_name}-public-nacl"
  }

  depends_on = [aws_subnet.public]
}

The web subnet NACLs for this module.

resource "aws_network_acl" "web" {
  vpc_id     = aws_vpc.this.id
  subnet_ids = [aws_subnet.web[0].id, aws_subnet.web[1].id]

  # Ingress rules
  # Allow all local traffic
  ingress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = aws_vpc.this.cidr_block
    from_port  = 0
    to_port    = 0
  }

  # Allow HTTP web traffic from anywhere
  ingress {
    protocol   = 6
    rule_no    = 105
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  # Allow HTTPS web traffic from anywhere
  ingress {
    protocol   = 6
    rule_no    = 110
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 443
    to_port    = 443
  }

  # Allow the ephemeral ports from the internet
  ingress {
    protocol   = "6"
    rule_no    = 120
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  ingress {
    protocol   = "17"
    rule_no    = 125
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  # Egress rules
  # Allow all ports, protocols, and IPs outbound
  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "${var.vpc_name}-web-nacl"
  }
}

The App and Data subnet NACLs have been set to allow only local traffic. You may adjust these rules.

VPC Security Group

A default security group is created every time a new VPC is provisioned. Here I’ll just give it some tags and few generic rules.

# Modify the default security group
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id

  dynamic "ingress" {
    for_each = var.default_security_group_ingress
    content {
      self        = lookup(ingress.value, "self", null)
      cidr_blocks = compact(split(",", lookup(ingress.value, "cidr_blocks", "")))
      description = lookup(ingress.value, "description", null)
      from_port   = lookup(ingress.value, "from_port", 0)
      to_port     = lookup(ingress.value, "to_port", 0)
      protocol    = lookup(ingress.value, "protocol", "-1")
    }
  }

  dynamic "egress" {
    for_each = var.default_security_group_egress
    content {
      self        = lookup(egress.value, "self", null)
      cidr_blocks = compact(split(",", lookup(egress.value, "cidr_blocks", "")))
      description = lookup(egress.value, "description", null)
      from_port   = lookup(egress.value, "from_port", 0)
      to_port     = lookup(egress.value, "to_port", 0)
      protocol    = lookup(egress.value, "protocol", "-1")
    }
  }

  tags = merge(
    {
      Name = format("%s-default-security-group", var.vpc_name)
    },
    var.additional_tags
  )
}

Now the values for this security group are passed as a variable like so. Be sure to change the ports and protocols to meet your needs.

variable "default_security_group_ingress" {
  description = "List of maps of ingress rules to set on the default security group"
  type        = list(map(string))
  default = [
    {
      cidr_blocks = "10.0.0.0/16"
      description = "Allow all from the local network."
      from_port   = 0
      protocol    = "-1"
      self        = false
      to_port     = 0
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all HTTPS from the internet."
      from_port   = 443
      protocol    = "6"
      self        = false
      to_port     = 443
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all HTTP from the internet."
      from_port   = 80
      protocol    = "6"
      self        = false
      to_port     = 80
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all ephemeral ports from the internet."
      from_port   = 32768
      protocol    = "6"
      self        = false
      to_port     = 60999
    }
  ]
}

variable "default_security_group_egress" {
  description = "List of maps of egress rules to set on the default security group"
  type        = list(map(string))
  default = [
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all"
      from_port   = 0
      protocol    = "-1"
      self        = false
      to_port     = 0
    }
  ]
}

Here’s a link to the complete code so far in the dev branch. That’s it all for now! In the next post, we’ll add application load balancers, target groups, listeners, etc. For a more in-depth explanation of VPC resources checkout AWS technical documentation. Be sure to subscribe for more content like this!

AWS IAM groups and policies – Terraform

Let’s create a module to create and manage AWS IAM groups and policies with Terraform! In summary a Terraform module is one or more Terraform resources bundled together to be used a single Terraform resource. You can learn more about Terraform module’s here. If you have no idea what Terraform or Terragrunt then start here. Let’s code!

The Terraform module structure

aws-iam-group/
main.tf
vars.tf
README.md

The main file

The main.tf contains all the resources required to create AWS IAM groups and their policies. Notice this one uses three resources!

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

resource "aws_iam_policy" "this" {
name = var.policy_name
description = var.policy_description
policy = var.policy
}

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

The vars file

variable iam_group_name {}

variable policy_name {}

variable policy_description {}

variable policy {}

Here’s the AWS IAM groups and policies Terraform module on GitHub: https://github.com/masterwali/tf-module-aws-iam-group

Create AWS IAM groups and policies with Terraform

Let’s use the Terraform module to create one or many IAM groups and their policies! You can deploy with Terraform but I like to use Terragrunt.

The Terragrunt/Terraform structure

This is one way to create your deployment structure. You can name this directory anything you like.

aws-iam
├── LICENSE
├── README.md
├── cloud-engineers-policy.tf
├── database-admins-policy.tf
├── developers-policy.tf
├── network-admins-policies.tf
├── groups.tf
├── main.tf
├── dev
│   ├── inputs.yml
│   ├── terragrunt.hcl
│   └── vars.tf
├── prod
│   └── terragrunt.hcl
│   ├── inputs.yml
│   └── vars.tf
├── qa
│   └── terragrunt.hcl
│   ├── inputs.yml
│   └── vars.tf
├── sec
│   └── terragrunt.hcl
│   ├── inputs.yml
│   └── vars.tf
└── terragrunt.hcl

In this example we will be creating groups and policies for developers, cloud engineers, database admins, and network admins all with one and same Terraform module! Using a module will ensure consistency and governance for our AWS resources.

The Terraform files

I like to keep the main file clean. I mainly use it for data calls and local variables.

main.tf

locals {
  account_id = data.aws_caller_identity.current.account_id
}

provider "aws" {
  region  = var.aws_region
  profile = var.aws_cli_profile
}

terraform {
  backend "s3" {}
}

data "aws_caller_identity" "current" {}

groups.tf

# CLOUD ENGINEERS
module "cloud_engineers" {
  source = "git@github.com:masterwali/tf-module-aws-iam-group.git"

  iam_group_name     = "cloud-engineers"
  policy_name        = "cloud-engineers"
  policy_description = "Cloud Engineers policy"
  policy             = data.aws_iam_policy_document.cloud_engineers.json
}
# ......... etc. 

# NETWORK ADMINS
# Modules creates group and policy and attaches policy to group. 
module "network_admins" {
  source = "git@github.com:masterwali/tf-module-aws-iam-group.git"

  iam_group_name     = "network-admins"
  policy_name        = "network-admins"
  policy_description = "Network Admins policy"
  policy             = data.aws_iam_policy_document.network_admins_main.json
}
# Create the network admins misc policy 
resource "aws_iam_policy" "network_admins_misc" {
  name        = "network-admins-misc"
  description = "Network Admins"
  policy      = data.aws_iam_policy_document.network_admins_misc.json
}
# Attach the above policy to the network admins group. 
resource "aws_iam_group_policy_attachment" "network_admins_misc" {
  group      = "network-admins"
  policy_arn = aws_iam_policy.network_admins_misc.arn

  depends_on = [aws_iam_policy.network_admins_misc]
}

The “cloud engineers” IAM policy file

data "aws_iam_policy_document" "cloud_engineers" {
  statement {
    sid    = "FullAccess"
    effect = "Allow"
    actions = [
      "iam:*",
      "kms:*",
      "s3:*"
    ]
    resources = ["*"]
  }
}

To see the developers policy and more take a look at the complete code at https://github.com/masterwali/aws-iam

Now let’s apply the code by navigating to the desired environment directory. Then initiate with ‘terragrunt init’ and apply with ‘terragrunt apply’. More on Terragrunt initiation, plans and apply.

Subscribe to get notified when more AWS and Terraform code is published!

IAM

AWS IAM console groups
AWS IAM console: Groups
AWS IAM console group policies
AWS IAM Group policies

AWS KMS Customer Managed CMK with Terraform

AWS Key Management Service (KMS) is a AWS managed service that allows us to create, manage, and delete customer master keys (CMK) or simply use AWS customer managed keys for encrypting our data in the AWS cloud. From my experience with passing both the AWS Certified Security – Speciality and the AWS Certified Solutions Architect – Professional, AWS Key Management Service is a must to learn inside and out. If you can understand all of the KMS features you’ll have a better chance of passing those two exams. Let’s learn how to create and manage AWS KMS customer managed CMK with Terraform! I will also be using Terragrunt so we can follow the DRY (Don’t repeat yourself) model.

This is part two of AWS Key management service (KMS) – Part 1.

KMS key types
KMS Key types

Key properties

  • Key Id
  • Creation date
  • Description
  • Key state

Customer managed keys (CMK)

Given the name you can guess the differences right away, right? For starter, you as the customer will have to explicitly create the key with AWS CLI, AWS API, or Terraform or any other available methods. You can set the CMK policies to allow services or users to use the key. The key policy can pass the permission responsibilities to be managed by IAM policies instead of the KMS CMK key policies. A CMK can be set to enable or disable at any time to allow usage or stop the usage of the key.

Key Alias are a great way to tag and identify Customer managed CMKs. This will help the user quickly find the desired key in the AWS console or AWS CLI query results. Since rotation is possible and it can be enabled to automatically rotate on yearly basis. Key aliases helps with that by re-assigning the alias to the new key.

You can definitely delete the key, but you must be damn sure that no one or any data or services is using that key! Once the key is gone you cannot unencrypt the data that was encrypted with the deleted key! So in order to semi control this chaos AWS has enforced a scheduling delete functionality rather than immediate delete. The customer can delete any Customer managed key by scheduling a delete; the minimum number of days to schedule a delete is 7 days. Best practice is to set this for a month or more.

NOTE: Before proceeding

  • If you haven’t installed Terraform or Terragrunt then you must follow this guide
  • For Terraform deep dive explanation follow this guide

Pricing

Each customer master key (CMK) that you create in AWS Key Management Service (KMS) costs $1/month until you delete it. For the N. VA region:

  • $0.03 per 10,000 requests
  • $0.03 per 10,000 requests involving RSA 2048 keys
  • $0.10 per 10,000 ECC GenerateDataKeyPair requests
  • $0.15 per 10,000 asymmetric requests except RSA 2048
  • $12.00 per 10,000 RSA GenerateDataKeyPair requests

Learn more at https://aws.amazon.com/kms/pricing/

Create and edit KMS Keys

Terraform module: main.tf

# Creates/manages KMS CMK
resource "aws_kms_key" "this" {
  description              = var.description
  customer_master_key_spec = var.key_spec
  is_enabled               = var.enabled
  enable_key_rotation      = var.rotation_enabled
  tags                     = var.tags
  policy                   = var.policy
  deletion_window_in_days  = 30
}

# Add an alias to the key
resource "aws_kms_alias" "this" {
  name          = "alias/${var.alias}"
  target_key_id = aws_kms_key.this.key_id
}

Terraform module: vars.tf

variable description {}

# Options available
# SYMMETRIC_DEFAULT, RSA_2048, RSA_3072,
# RSA_4096, ECC_NIST_P256, ECC_NIST_P384,
# ECC_NIST_P521, or ECC_SECG_P256K1
variable key_spec {
  default = "SYMMETRIC_DEFAULT"
}

variable enabled {
  default = true
}

variable rotation_enabled {
  default = true
}

variable tags {}

variable alias {}

variable policy {}

Terragrunt KMS directory structure

terragrunt directory structure

Terraform plan

main.tf 

locals {
  admin_username = "waleed"
  account_id     = data.aws_caller_identity.current.account_id
}

provider "aws" {
  region  = var.aws_region
  profile = var.aws_cli_profile
}

terraform {
  backend "s3" {}
}

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "ssm_key" {
  statement {
    sid       = "Enable IAM User Permissions"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${local.account_id}:root"]
    }
  }

  statement {
    sid       = "Allow access for Key Administrators"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }
  }

  statement {
    sid    = "Allow use of the key"
    effect = "Allow"
    actions = [
      "kms:Encrypt",
      "kms:Decrypt",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:DescribeKey"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }
  }

  statement {
    sid    = "Allow attachment of persistent resources"
    effect = "Allow"
    actions = [
      "kms:CreateGrant",
      "kms:ListGrants",
      "kms:RevokeGrant"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }

    condition {
      test     = "Bool"
      variable = "kms:GrantIsForAWSResource"
      values   = ["true"]
    }
  }
}

ssm-key.tf : It’s best practice to have each type of key in its own terraform file. In this example this key is for SSM. You can create keys for any services possible in your region!

Notice the source is only pulling the ‘dev’ branch. Once tested and verified the source branch would change in each environment.

module "ssm" {
  source = "git@github.com:S-Waleed/terraform-aws-kms-module.git"

  description = "KMS key for System Manager"
  alias       = "ssm"
  policy      = data.aws_iam_policy_document.ssm_key.json

  tags = {
    Name  = "ssm"
    Owner = "wsarwari"
  }
}
# module.ssm.aws_kms_alias.this will be created
  + resource "aws_kms_alias" "this" {
      + arn            = (known after apply)
      + id             = (known after apply)
      + name           = "alias/ssm"
      + target_key_arn = (known after apply)
      + target_key_id  = (known after apply)
    }

# module.ssm.aws_kms_key.this will be created
  + resource "aws_kms_key" "this" {
      + arn                      = (known after apply)
      + customer_master_key_spec = "SYMMETRIC_DEFAULT"
      + deletion_window_in_days  = 30
      + description              = "KMS key for System Manager"
      + enable_key_rotation      = true
      + id                       = (known after apply)
      + is_enabled               = true
      + key_id                   = (known after apply)
      + key_usage                = "ENCRYPT_DECRYPT"
      + policy                   = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "kms:*"
                      + Effect    = "Allow"
                      + Principal = {
                          + AWS = "arn:aws:iam::111122223334:root"
                        }
                      + Resource  = "*"
                      + Sid       = "Enable IAM User Permissions"
                    },
                  + {
                      + Action    = "kms:*"
                      + Effect    = "Allow"
                      + Principal = {
                          + AWS = [
                              + "arn:aws:iam::111122223334:user/waleed",
                              + "arn:aws:iam::111122223334:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor",
                              + "arn:aws:iam::111122223334:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
                            ]
                        }
                      + Resource  = "*"
                      + Sid       = "Allow access for Key Administrators"
                    },
                  + {
                      + Action    = [
                          + "kms:ReEncrypt*",
                          + "kms:GenerateDataKey*",
                          + "kms:Encrypt",
                          + "kms:DescribeKey",
                          + "kms:Decrypt",
                        ]
                      + Effect    = "Allow"
                      + Principal = {
                          + AWS = [
                              + "arn:aws:iam::111122223334:user/waleed",
                              + "arn:aws:iam::111122223334:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor",
                              + "arn:aws:iam::111122223334:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
                            ]
                        }
                      + Resource  = "*"
                      + Sid       = "Allow use of the key"
                    },
                  + {
                      + Action    = [
                          + "kms:RevokeGrant",
                          + "kms:ListGrants",
                          + "kms:CreateGrant",
                        ]
                      + Condition = {
                          + Bool = {
                              + kms:GrantIsForAWSResource = "true"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + AWS = [
                              + "arn:aws:iam::111122223334:user/waleed",
                              + "arn:aws:iam::111122223334:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor",
                              + "arn:aws:iam::111122223334:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
                            ]
                        }
                      + Resource  = "*"
                      + Sid       = "Allow attachment of persistent resources"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + tags                     = {
          + "Name"  = "ssm"
          + "Owner" = "wsarwari"
        }
    }

Plan: 2 to add, 0 to change, 0 to destroy.

After applying the code you should see the key in Key Management Service.

kms key created

Enable and disable CMKs

Simple, just update the “enabled” variable to false! Plan and apply.

module "ssm" {
  source = "git@github.com:S-Waleed/terraform-aws-kms-module.git"

  description = "KMS key for System Manager"
  alias       = "ssm"
  ...
  enabled     = false

  ....
}

Automatic rotation

Another easy change with Terraform! By default I have set it to rotate the key in the custom module I created above. Now if you want to disable automatic rotation then all you have to do is set the variable rotation_enabled to false.

module "ssm" {
  source = "git@github.com:S-Waleed/terraform-aws-kms-module.git"

  description = "KMS key for System Manager"
  alias       = "ssm"
  ...
  rotation_enabled     = false

  ....
}

Key Alias

Keys are identified by randomly generated GUIDs. It’s best to create in alias for each key so it can be easily identified by everyone. Aliases are also easy to create and update in Terraform. In the custom module above I have added the ability to create the key alias right after the key is provisioned. The key alias is also passed as a variable as shown below.

module "ssm" {
  source = "git@github.com:S-Waleed/terraform-aws-kms-module.git"

  description = "KMS key for System Manager"
  alias       = "ssm"
  ...
}
Type of CMKCan view CMK metadataCan manage CMKUsed only for my AWS accountAutomatic rotation
Customer managed CMKYesYesYesOptional. Every 365 days (1 year).
AWS managed CMKYesNoYesRequired. Every 1095 days (3 years).
AWS owned CMKNoNoNoVaries
This chart is from the AWS documentation

Key Policy

Each key must have a policy that allows or denies the key to be used by users or services. In the design that I have created allows you to give each key a policy. Let’s breakdown each statement of the key. 

Enable IAM User Permissions

statement {
    sid       = "Enable IAM User Permissions"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${local.account_id}:root"]
    }
  }

This statement allows all users and services in this account to execute all KMS actions on this key. It’s best practice to follow up on this open statement by creating an IAM policy to restrict the key usage. The identifiers in the principles section can be other AWS accounts.

Allow access for Key Administrators

statement {
    sid       = "Allow access for Key Administrators"
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }
  }

This statement allows selected individual users and IAM roles to fully manage this key. This statement must exist for every single key. Without this statement you will absolutely lose access and management of this key.

Allow use of the key

statement {
    sid    = "Allow use of the key"
    effect = "Allow"
    actions = [
      "kms:Encrypt",
      "kms:Decrypt",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:DescribeKey"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }
  }

This statement is specifically for the usage of the key. If you do not provide the statement Enable IAM User Permissions then you must include this statement; Otherwise this key will not be usable by anyone besides the key administrators.

Allow attachment of persistent resources

statement {
    sid    = "Allow attachment of persistent resources"
    effect = "Allow"
    actions = [
      "kms:CreateGrant",
      "kms:ListGrants",
      "kms:RevokeGrant"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${local.account_id}:user/${local.admin_username}",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/support.amazonaws.com/AWSServiceRoleForSupport",
        "arn:aws:iam::${local.account_id}:role/aws-service-role/trustedadvisor.amazonaws.com/AWSServiceRoleForTrustedAdvisor"
      ]
    }

    condition {
      test     = "Bool"
      variable = "kms:GrantIsForAWSResource"
      values   = ["true"]
    }
  }

This statement allows listing, creating, and revoking grants for the key by the principals identified in the statement.

Deleting the key

Do not delete production keys! You must be 100% sure no service or data has ever used the key you are able to delete. Once a key is deleted it’s not possible to restore! You cannot decrypt data that was encrypted with the key that you delete. In Terraform you can delete the key by deleting the code or commenting out the code, then Terraform plan and apply. Or if you want to keep the code and just want to remove the resource from AWS then execute Terragrunt destroy. This destroy will also release the alias so it can be reused. 

kms key pending deletion
Key deletion


Status is now pending deletion. It will delete this key 30 days from the day of the destroy. 

Stop KMS key deletion

If you decide to not delete it then on the AWS console you can select the key then click on Key actions. Finally select Cancel key deletion. This option is only available before the deletion date. 

Cancel key deletion
Key actions

Read more about deleting KMS customer managed keys.

KMS Multi-Region Keys

Take a look at Terraform AWS KMS Multi-Region Keys.

As always if you see any errors, mistakes, have suggestions or questions please comment below. Don’t forget to like, share, and subscribe below for more! 

AWS managed CMKs

I talked about AWS managed customer master keys in my previous post here.

Complete Code

Get the complete module from https://github.com/masterwali/terraform-aws-kms-module.

AWS Account settings with Terraform and terragrunt Part 2

This is continuation of AWS account settings as code with Terraform and Terragrunt. Be sure to start with part one. Part will I’ll be blocking Amazon S3 bucket public access, enable EBS volume encryption at the AWS account level, and apply the IAM account password policies.

Cost: These exact settings applied on the account have no cost unless you use customer managed keys from KMS.

AWS IAM account password policies

# password policy
resource "aws_iam_account_password_policy" "this" {
  minimum_password_length        = 10
  max_password_age               = 365
  password_reuse_prevention      = 10
  require_lowercase_characters   = true
  require_numbers                = true
  require_uppercase_characters   = true
  require_symbols                = true
  allow_users_to_change_password = true
}

This applies the IAM account password settings as code.

This change requires the IAM permission “UpdateAccountPasswordPolicy” action allowed.

Update your “settings” repository dev branch. Then in the “settings” terragrunt update your code with

terragrunt init --terragrunt-source-update

terragrunt plan

# then
terragrunt apply

Check by going to the IAM service dashboard… now have a green check mark for “Apply an IAM password policy”.

Block Amazon S3 bucket public access

So many horror stories on the news about Amazon Simple Storage Service (S3) buckets being accidentally open to the public. Let’s prevent accidental public access on S3 buckets at the account level just in case if you get to block at the bucket level.

resource "aws_s3_account_public_access_block" "this" {
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

This sets the following settings on S3. As stated on the S3 “Block public access…” in the S3 console.

  • Blocks public access to buckets and objects granted through new access control lists (ACLs)
  • Blocks public access to buckets and objects granted through any access control lists (ACLs)
  • Blocks public access to buckets and objects granted through new public bucket or access point policies
  • Blocks public and cross-account access to buckets and objects through any public bucket or access point policies

You’ll need “PutAccountPublicAccessBlock” S3 action for this setting.

Update your “settings” repository dev branch. Then in the “settings” terragrunt update your code with

terragrunt init --terragrunt-source-update

terragrunt plan

# then
terragrunt apply

Verify by going to S3 service -> on the left navigation click “Block public access (account settings)“. You should see all green “On” for every single line.

block s3 public settings

Default EBS volume encryption

This account level setting will always set EC2 default EBS volume encryption during creation of any EBS volume regardless of what and how it’s provisioned. If you provision an EC2 using the console or use of any of the AWS CLI commands or any of the AWS SDKS and you don’t explicitly apply EBS volume encryption then this will do it for you! It’s quite amazing and simple to apply or remove.

What key will it use? It can use a default Amazon managed key or your a customer (you) managed KMS key. I haven’t setup KMS keys yet, so I’ll use the default Amazon managed key for now.

resource "aws_ebs_encryption_by_default" "this" {
  enabled = true
}

You’ll need the following IAM policy statement to apply this setting.

{
            "Sid": "AllowsEBSdefaultEncryption",
            "Effect": "Allow",
            "Action": [
                "ec2:GetEbsEncryptionByDefault",
                "ec2:EnableEbsEncryptionByDefault",
                "ec2:DisableEbsEncryptionByDefault",
                "ec2:ResetEbsDefaultKmsKeyId",
                "ec2:ModifyEbsDefaultKmsKeyId"
            ],
            "Resource": "*"
        }

and again, update your Terraform git repository then update your Terragrunt deployment code and apply.

Navigate to the EC2 service then on the main page on the right panel within the “Account attributes” click on “EBS encryption“.

Due note this only applied for a single region!

Bonus

Billing settings

These aren’t automated but it’s only enabled once your consolidated billing account. If you have permissions to enable billing alerts and emails. Within the Billing Preferences select the following settings.

Sends a PDF version of your invoice by email
As stated get notified on your free tier usage and cost

That’s it for now!

As always if you see any errors, mistakes, have suggestions or questions please comment below. Don’t forget to like, share, and subscribe for more!

Image by Денис Марчук from Pixabay