Incorporating AWS Security Best Practices Into Terraform Design

Implementing AWS security best practices into your Terraform design is an excellent way of ensuring that you have a streamlined way to achieve your security goals and manage your infrastructure.

In this post, we will talk about the following three areas of AWS security best practices and how to implement them with Terraform:

  • Environment segregation by AWS account
  • CloudTrail logging
  • Traffic and system access controls

Just to be clear, this post is not an introduction to Terraform: It’s an introduction to incorporating AWS security best practices into Terraform code.

Note: If you need background information on Terraform, take a look at the following for a comprehensive guide as well as practical tips:

Environment Segregation by AWS Account

One of the most valuable design decisions you can make with AWS is using multiple AWS accounts so that Prod, Dev, and QA/Testing each have their own account. Unfortunately, this practice is not widely followed for a number of reasons: Setup involves more work, billing becomes more complicated, and maintenance requires that multiple environments be kept in parity with one another. Also, some people just haven’t thought of segregation by account. I didn’t until I saw it here at Threat Stack. But the idea that Dev, QA, and Prod environments should be kept in sync is a fundamental idea that we accept as important for ensuring reliable systems.

Why are multiple AWS accounts useful?

From an engineering and reliability point of view, if all environments are kept in sync, it means the effects of changes to the environment can be tested in isolation. It’s better for an idea to fail in Dev than it is to fail in Prod. I want a developer to spin up the resources they need to get their work done and destroy them when they’re finished in Dev. I don’t want developers doing that in Prod. You could do this with complex AWS IAM policies, but a simpler and less intensive solution would be to grant wider permissions to engineers in the Dev AWS account and to restrict permissions— or even deny permission — in the Prod AWS account.

Let’s also look at how multiple accounts enhance the environment’s security stance. First, keep in mind that oftentimes, attackers don’t directly breach the most valuable target. They move laterally within the environment until they reach something of value. Second, let’s be honest: Non-production systems are often not treated with the same level of care that production systems are. Sure you have restricted access to your production systems… But there’s an exception so your deploy system running via Jenkins can login to deploy new code. How secure are your Jenkins hosts? Are you confident that they are well secured and that an an attacker couldn’t use them to gain lateral access to a production system? By segregating environments by account, you are limiting the movement an intruder can make in your environment.

So back to the question of why organizations don’t separate environments by account. To handle billing complexity, AWS offers Consolidated Billing and rolling all bills into a single account. AWS now offers AWS Organizations the ability to make the management of multiple accounts even easier. To handle the complexity of environment maintenance and keeping parity across environments, we are turning our AWS setup into Terraform code. We are doing this because setting up new VPCs by hand is complicated and takes considerable time from a number of resources both in initial work and then troubleshooting to figure out why two hosts in different subnets cannot communicate with one another. When making changes after the VPC is up and running, we are running terraform apply across multiple environments instead of making the same change across multiple environments by hand.

Terraform Code

So how do you handle multiple environments in Terraform? If you are a regular Terraform user, you have asked yourself this question before. It’s one of the most routine questions with Terraform: “How do I design my Terraform code structure so I can apply changes on a per environment basis?” You want to make a change and have it only affect Dev resources. Then when you’re ready, you want to apply it to your Prod resources. In Terraform terms you ask, “How do I manage a state file per environment?” (Terraform tracks all changes in a state file that can live locally or remotely.) People have solved this in a variety of ways. Some have used multiple directories and usually some symlinks to cut down on common code. Some write wrapper scripts. And many have combined those ideas. Terraform today might remind you of the early days of Puppet and Chef when the best practices hadn’t really been worked out yet. (Remember Puppet before “roles and profiles”?)

By choosing to segregate your environments by AWS account, you inadvertently solve this Terraform question. You actually can’t touch resources in Prod and Dev at the same time because you are only using a single set of AWS credentials when Terraform runs. The AWS SDKs let you set up multiple account credential profiles in a file named “$HOME/.aws/credentials”. The format looks like this:

~/.aws/credentials

[example-dev]
aws_access_key_id = <AWS_ACCESS_KEY_EXAMPLE_DEV>
aws_secret_access_key = <AWS_SECRET_ACCESS_KEY_EXAMPLE_DEV>

[example-prod]
aws_access_key_id = <AWS_ACCESS_KEY_EXAMPLE_PROD>
aws_secret_access_key = <AWS_SECRET_ACCESS_KEY_EXAMPLE_PROD>

The profile used is controlled by the $AWS_PROFILE environment variable of the shell. I even go as far as incorporating the current profile into my shell prompt:

[tmclaughlin@tomcat-ts:example-dev ~]$ echo $AWS_PROFILE
example-dev

Terraform respects all this and lets you override these settings in the AWS provider resource if you prefer to do it that way instead of relying on the shell environment.

So now you have no concept of environment in Terraform — just accounts— and don’t have to worry about changes to one environment affecting another environment when you run terraform apply. Let’s handle different account configurations in Terraform. You want all your environments to look as much the same as possible. However, you don’t want Dev to be a complete replica of the Prod environment. Why pay for thirty hosts in Dev when three would handle all that was needed for the engineering team? What you want is the same environment structure and design between different environments — but resized to meet the needs of those environments. This requires you to separate configuration data from infrastructure logic.

Here is an example of this idea. The repository for it is located here:

[tmclaughlin@tomcat-ts:example-dev terraform-example-service(master)]$ tree
.
├── README.md
├── example-dev.tfvars
├── example-prod.tfvars
└── main.tf

0 directories, 4 files

The main.tf file contains a resource to create infrastructure using a Terraform module. The module handles AWS infrastructure resources like security groups and auto scaling groups:

// Deploy this service using our module
module "svc" {
  source                = "github.com/threatstack/tf_example_svc"
  svc_name              = "${var.svc_name}"
  aws_account           = "${var.aws_account}"
  aws_region            = "${var.aws_region}"
  subnet_type           = "${var.subnet_type}"
  asg_min_size          = "${var.asg_min_size}"
  asg_max_size          = "${var.asg_max_size}"
  asg_desired_capacity  = "${var.asg_desired_capacity}"
  security_group_service_ingress_external = "${var.security_group_service_ingress}"
}

The differences between the Dev and Prod Example Service are contained in the tfvars files. They are mostly the same with the exception of the asg_* variables to reflect the difference in ASG size:

example-dev.tfvars

asg_min_size            = 3
asg_max_size            = 3
asg_desired_capacity    = 3

example-prod.tfvars

asg_min_size            = 30
asg_max_size            = 40
asg_desired_capacity    = 60

The Benefits of Environment Segregation

The Terraform design discussed above implements an AWS best practice of environment segregation that allows lateral movements to be better contained in the event of a breach. Terraform has also solved the issue of environment segregation in Terraform so that changes applied to one environment cannot concurrently affect another environment.

CloudTrail Logging

Setting up CloudTrail logging is one of the simplest things you can do in your environment to create better visibility and auditability. CloudTrail logs all AWS API calls in your account and delivers those logs to an S3 bucket. The logs will include the identity of the caller, time of call, source IP, request parameters, and response elements. This includes both calls made through SDKs or CLI tools and the Console. With these logs, you can use tools to receive alerts on suspicious usage or changes, or to perform analysis after an event.

Setting up CloudTrail logging is easy, but too often it is overlooked. Enabling it will help track down changes in the AWS environment. Here are some of the important CloudTrail configurations that need to be made:

  • CloudTrail enabled in all regions
  • Log file validation is enabled
  • Log S3 bucket is not publically accessible
  • Access to S3 Log bucket is logged

Optionally, these are also useful:

  • CloudTrail to CloudWatch delivery
  • Log encryption via KMS

We will discuss all of these with the exception of Encryption, because KMS requires its own indepth dive.

CloudTrail Setup

The tf_example_aws_cloudtrail module provides variables for configuring multi-region trails and validation:

variable "enable_log_file_validation" {
  description = "Create signed digest file to validated contents of logs."
  default = true
}

variable "is_multi_region_trail" {
  description = "Whether the trail is created in all regions or just the current region."
  default = false
}

The enable_log_file_validation variable ensures that a digitally signed hash will be created so log file contents can be validated. This is useful for ensuring the integrity of CloudWatch. The is_multi_region_trail variable defaults to False. Ensure that a multi-region CloudTrail is setup. Why? Because you want to catch events in any region where you do have resources and in any region where you don’t have anything setup…  Or at least you think you don’t have anything set up.

Looking at the example environments, Prod and Dev are only in us-east-1, so they have multi-region trails enabled:

S3 Bucket Access

You want to ensure that the S3 bucket that logs are delivered to isn’t wide open to the world and that access to the logs is recorded. The tf_example_aws_s3 module used by tf_example_aws_cloudtrail does all this by default:

variable "s3_bucket_acl" {
  type = "string"
  description = "S3 bucket ACL"
  default = "private"
}

variable "s3_logs_bucket" {
  type = "string"
  description = "Bucket for storing AWS access logs"
  default = "default"
}

# Resources
resource "aws_s3_bucket" "bucket" {
  # This is to keep things consistent and prevent conflicts across
  # environments.
  bucket = "${var.aws_s3_prefix}-${var.aws_account}-${var.s3_bucket_name}"
  acl    = "${var.s3_bucket_acl}"
  versioning = {
    enabled = "${var.versioning}"
  }
  logging = {
    # If the value is default, get the bucket name from the root state.
    target_bucket = "${var.s3_logs_bucket == "default" ? data.terraform_remote_state.root.aws_s3_bucket_infra_logs_bucket_id : var.s3_logs_bucket}"
    target_prefix = "s3/${var.aws_s3_prefix}-${var.aws_account}-${var.s3_bucket_name}/"
  }
  tags = {
    terraform = "true"
  }
}

Access to the bucket by CloudTrail is controlled by the bucket policy created in tf_example_aws_cloudtrail:

resource "aws_s3_bucket_policy" "bucket" {
  bucket = "${module.aws_cloudtrail_s3_bucket.bucket_id}"
  policy = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AWSCloudTrailAclCheck",
            "Effect": "Allow",
            "Principal": {
              "Service": "cloudtrail.amazonaws.com"
            },
            "Action": "s3:GetBucketAcl",
            "Resource": "arn:aws:s3:::${module.aws_cloudtrail_s3_bucket.bucket_id}"
        },
        {
            "Sid": "AWSCloudTrailWrite",
            "Effect": "Allow",
            "Principal": {
              "Service": "cloudtrail.amazonaws.com"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::${module.aws_cloudtrail_s3_bucket.bucket_id}/*",
            "Condition": {
                "StringEquals": {
                    "s3:x-amz-acl": "bucket-owner-full-control"
                }
            }
        }
    ]
}
POLICY
}

This is also an example of why I am a strong advocate of providing modules with sane defaults in Terraform (and Puppet, Chef, etc.). Rather than people creating resources in Terraform directly, which usually means copying around boilerplate code, they use modules that provide enough safety and require them to explicitly set unsafe configurations. This makes the code easier to review. Just compare the contents of the tf_example_aws_s3 module with the resource needed in tf_example_aws_cloudtrail to create an S3 bucket:

module "aws_cloudtrail_s3_bucket" {
  source = "github.com/threatstack/tf_example_aws_s3"
  s3_bucket_name = "${var.s3_bucket_name}"
  versioning = true
  aws_account = "${var.aws_account}"
  aws_region = "${var.aws_region}"
}

CloudTrail to CloudWatch

Finally, CloudTrail logs can be streamed to CloudWatch. With CloudTrail events in CloudWatch, they can now be viewed just like any other log.

This requires creating a CloudWatch logstream and an IAM role with a policy attached to it that allows delivery from the CloudTrail trail to the CloudWatch log stream:

resource "aws_cloudwatch_log_group" "ct" {
  name = "/aws/cloudtrail/${var.aws_cloudtrail_name}"
  tags {
    terraform = "true"
  }
}

resource "aws_iam_role" "ct" {
  name = "cloudtrail-to-cloudwatch-${var.aws_cloudtrail_name}"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudtrail.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_policy" "ct" {
  name = "cloudtrail-to-cloudwatch-${var.aws_cloudtrail_name}"
  description = "Deliver logs from CloudTrail to CloudWatch."
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AWSCloudTrailCreateLogStream",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogStream"
      ],
      "Resource": [
        "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/aws/cloudtrail/${var.aws_cloudtrail_name}:log-stream:${var.aws_account_id}_CloudTrail_${var.aws_region}*"
      ]
    },
    {
      "Sid": "AWSCloudTrailPutLogEvents",
      "Effect": "Allow",
      "Action": [
        "logs:PutLogEvents"
      ],
      "Resource": [
        "arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/aws/cloudtrail/${var.aws_cloudtrail_name}:log-stream:${var.aws_account_id}_CloudTrail_${var.aws_region}*"
      ]
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "ct" {
  role = "${aws_iam_role.ct.name}"
  policy_arn = "${aws_iam_policy.ct.arn}"
}

Traffic and System Access Controls

I touched on one reason why people don’t use multiple accounts, and that was the complexity of VPC setup. Good news! Here’s an example Terraform setup for a VPC:

Now let’s take a look at the design decisions made to create a better security posture. The decisions primarily center on the way subnets and security groups are handled. We want to limit hosts so they can only talk to the hosts they need to talk to.

This sort of practice used to be fairly common in the pre-cloud days. If system A had to talk to system B, you put in a ticket to Network Engineering for them to allow traffic. When people started moving to the cloud, there was no Network Engineering team, and the idea of putting in tickets to allow access between systems slowed down the promise of speedy service delivery via the cloud. The result? Many organizations allowed every host in the default security group (which contained every host in their environment) to talk to every other host on every port.

Problem solved! Well, problem solved from a speed of service delivery standpoint — but terrible for security. But now with your AWS infrastructure as code, you can default to denying all traffic and selectively opening traffic up as a part of service delivery.

We are going to approach traffic segmentation through the way we build VPC subnets and security groups. There will be a single VPC with a set of private subnets and a set of public subnets. By default, the security groups will not allow traffic between public and private subnets. Then we will selectively allow traffic where necessary. Finally, we will only allow SSH access to the environment from a bastion host and show how we allow traffic from the bastion host to other areas of the environment.

First a quick note on VPC-based segmentation. I went down that path initially, and if you go through the history of the example repository, you will see where I had a private and a public VPC. I opted not to go that route after I got to AWS Route53 setup. I like to place all hosts in my environment in a global domain space. Typically I use . as a convention. This means that as long as you know the instance ID of the host, you can get to it quickly. When I looked at Route53 Private Hosted Zones, I found that it is possible to share DNS zones across VPCs, but they cannot be in overlapping namespaces. At that point, I reevaluated my design. In the end I felt I could complete what I was looking for with just subnets and security groups. On the downside, I need to get my subnet sizes for public and private subnets correct based on what I think my needs will be. With multiple VPCs there is greater room for over estimation, which is helpful. If you want to understand more about different ways to handle VPCs, have a look here:

Multiple Subnets

Our Terraform design has two types of subnets — public and private — and we create a public and private subnet for each availability zone we use. (In us-east-1, we only use three AZs. As a general rule, sticking to odd numbers is helpful because it makes consensus decisions easier.) In our setup, instances in a public subnet get a publicly routable IP address, while those in the private subnet do not.

resource "aws_subnet" "private" {
  count                   = "${length(var.private_subnets)}"
  vpc_id                  = "${aws_vpc.vpc.id}"
  cidr_block              = "${var.private_subnets[count.index]}"
  availability_zone       = "${var.subnet_availability_zones[count.index]}"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.vpc_name}-${var.subnet_availability_zones[count.index]}-private"
    terraform = "true"
  }
}

resource "aws_subnet" "public" {
  count                   = "${length(var.public_subnets)}"
  vpc_id                  = "${aws_vpc.vpc.id}"
  cidr_block              = "${var.public_subnets[count.index]}"
  availability_zone       = "${var.subnet_availability_zones[count.index]}"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.vpc_name}-${var.subnet_availability_zones[count.index]}-public"
    terraform = "true"
  }
}

Anything that is an edge-facing service should end up in the public VPC. These are hosts and services that we want to be able to account for and restrict movement from. These are hosts that could potentially be breached externally, so you want to make sure attackers can’t make it far in our environment.

Hosts in the public subnets reach the internet via an Internet Gateway associated with the default (0.0.0.0/0) route in the route tables for the public subnets:

resource "aws_internet_gateway" "internet_gateway" {
  count = "${var.gateway_enabled}"
  vpc_id = "${aws_vpc.vpc.id}"
  tags = {
    Name = "${var.vpc_name}"
    terraform = "true"
  }
}


resource "aws_route_table" "public" {
  count   = "${length(var.public_subnets)}"
  vpc_id  = "${aws_vpc.vpc.id}"
  tags = {
    Name = "${var.vpc_name}-${element(var.subnet_availability_zones, count.index)}-public"
    terraform = "true"
  }
}

resource "aws_route" "public_igw" {
  count                   = "${length(var.public_subnets) * var.gateway_enabled}"
  route_table_id          = "${element(aws_route_table.public.*.id, count.index)}"
  destination_cidr_block  = "0.0.0.0/0"
  gateway_id              = "${aws_internet_gateway.internet_gateway.id}"
  depends_on              = ["aws_route_table.public"]
}

resource "aws_route_table_association" "public" {
  count           = "${length(var.public_subnets)}"
  subnet_id       = "${element(aws_subnet.public.*.id, count.index)}"
  route_table_id  = "${element(aws_route_table.public.*.id, count.index)}"
}

Hosts in the private subnets reach the internet via NAT Gateways associated with the default (0.0.0.0/0) route in the route tables for the private subnets:

resource "aws_eip" "nat" {
  count         = "${length(var.public_subnets) * var.nat_enabled}"
  vpc           = true
}

resource "aws_nat_gateway" "nat_gateway" {
  count         = "${length(var.public_subnets) * var.nat_enabled}"
  allocation_id = "${element(aws_eip.nat.*.id, count.index)}"
  subnet_id     = "${element(aws_subnet.public.*.id, count.index)}"
}

resource "aws_route_table" "private" {
  count   = "${length(var.private_subnets)}"
  vpc_id  = "${aws_vpc.vpc.id}"
  tags = {
    Name = "${var.vpc_name}-${element(var.subnet_availability_zones, count.index)}-private"
    terraform = "true"
  }
}

resource "aws_route" "private_nat" {
  count                   = "${length(var.private_subnets) * var.nat_enabled}"
  route_table_id          = "${element(aws_route_table.private.*.id, count.index)}"
  destination_cidr_block  = "0.0.0.0/0"
  nat_gateway_id          = "${element(aws_nat_gateway.nat_gateway.*.id, count.index)}"
  depends_on              = ["aws_route_table.private"]
}

resource "aws_route_table_association" "private" {
  count           = "${length(var.private_subnets)}"
  subnet_id       = "${element(aws_subnet.private.*.id, count.index)}"
  route_table_id  = "${element(aws_route_table.private.*.id, count.index)}"
}

Security Groups

With hosts segregated by subnets, we can turn to the security groups. Segregating hosts into these multiple subnets would not be useful if we just let them all talk to each other with no restrictions. This is a common setup in AWS environments, but with Terraform, it doesn’t have to be. Being able to programmatically control AWS security groups means we can bring back the network controls of the pre-cloud days that limited movement across the network and retain the speed of service delivery that we have come to expect from the cloud.

In the example VPC setup, there are three “default” security groups: the “default” security group created with the creation of a VPC and additional SGs to be private and public subnet SGs:

resource "aws_default_security_group" "default" {
  vpc_id = "${aws_vpc.vpc.id}"
  tags = {
    Name      = "${var.vpc_name}-default"
    terraform = "true"
  }
}

resource "aws_security_group" "default_private" {
  name        = "default-private"
  description = "default VPC security group ${var.vpc_name} private subnets"
  vpc_id = "${aws_vpc.vpc.id}"
  tags = {
    Name      = "${var.vpc_name}-default-private"
    terraform = "true"
  }
}

resource "aws_security_group" "default_public" {
  name        = "default-public"
  description = "default VPC security group ${var.vpc_name} public subnets"
  vpc_id = "${aws_vpc.vpc.id}"
  tags = {
    Name      = "${var.vpc_name}-default-public"
    terraform = "true"
  }
}

When creating a new VPC, AWS creates the “default” security group with rules to facilitate traffic and get you up and running quickly and easily. Terraform immediately removes those rules after creating the VPC. This Terraform module continues that idea. While the “default” SG is defined as a resource, we never add any rules to it. We will not add a rule that affects all hosts in our environment!

The private and public “default” SGs are useful for some basic rules. First they let us define network egress for private and public subnets independently. We use the same egress rule for public and private, but the option is there if we wanted to use it. We also optionally create an ingress rule that allows SSH traffic among the hosts in the same subnet type. Private hosts can SSH to other private hosts. Public hosts can SSH to other public hosts. This rule is optional in the module.

Setting the variable private_subnets_allow_all or public_subnets_allow_all will disable that rule. Different organizations have different feelings about allowing SSH around the environment. For that reason, I made the rule a boolean. The module defaults to disabling the rule, and we explicitly disable it in our environment files. This feature is very useful if you want to allow SSH access in your Dev environment so engineers can work faster but want to disable the rule in the Prod environment where restrictions need to be tighter:

resource "aws_security_group" "default_private" {
  name        = "default-private"
  description = "default VPC security group ${var.vpc_name} private subnets"
  vpc_id = "${aws_vpc.vpc.id}"
  tags = {
    Name      = "${var.vpc_name}-default-private"
    terraform = "true"
  }
}

resource "aws_security_group_rule" "default_ssh_ingress_private" {
  count                     = "${var.private_subnets_allow_all}"
  type                      = "ingress"
  from_port                 = "${var.security_group_default_ingress_private["from_port"]}"
  to_port                   = "${var.security_group_default_ingress_private["to_port"]}"
  protocol                  = "${var.security_group_default_ingress_private["protocol"]}"
  source_security_group_id  = "${aws_security_group.default_private.id}"
  security_group_id         = "${aws_security_group.default_private.id}"
}

resource "aws_security_group_rule" "default_ssh_egress_private" {
  type                      = "egress"
  from_port                 = "${var.security_group_default_egress_private["from_port"]}"
  to_port                   = "${var.security_group_default_egress_private["to_port"]}"
  protocol                  = "${var.security_group_default_egress_private["protocol"]}"
  cidr_blocks               = ["${var.security_group_default_egress_private["cidr_blocks"]}"]
  security_group_id         = "${aws_security_group.default_private.id}"
}

Bastion Hosts

How do you manage such a locked down environment with no SSH access to anything and segregated subnets with no ability to talk to each other? In comes the Bastion Host. This is the host all users must log into first in order to reach any other host in the environment. The Bastion Host service illustrates two things:

  • The best practice of focusing user logins through a single choke point
  • How to create SG rules that allow access from one service to another

Here is a basic Bastion service in Terraform:

It uses a Terraform module called tf_example_svc which defines what a service should look like in the Example environment:

The Bastion Hosts should be the focus point for user access to the AWS environment. In practice that means that this host should have additional security measures in place (for example, requiring 2FA in order to SSH into the host). If you can’t easily monitor the access logs for all your hosts, at least make sure you’re monitoring access to this host. Don’t leave SSH access open to the entire internet even if it is “just your public subnet hosts”. Use a Bastion Host.

What makes this an interesting Terraform example is the SG rules that we create. In terraform-example-bastion, we create an additional security rule that allows access from the Bastion Host to hosts in both the public and private VPCs. We create the security groups in terraform-example-aws-vpc, but the rule to make this service useful is created by terraform-example-bastion. I didn’t really cover this previously, but our Terraform repositories store the state of the resources they manage remotely, in our case in an S3 bucket.

Remote state usage and its advantages (such as infrastructure auditability) is its own in-depth topic. To learn more about it take a look here:

In the tfvars files you list the subnets you want to grant access to and on what port:

# We control what this bastion is able to talk to from here.
security_group_access = ["public", "private"]
security_group_default_ingress = {
  from_port             = 22
  to_port               = 22
  protocol              = "tcp"
}

In main.tf you query the VPC remote state to get the default security group IDs for the public and private subnets and then create a rule to allow SSH traffic to the hosts in those subnets:

// Remote state for aws_vpc.
data "terraform_remote_state" "aws_vpc" {
  backend = "s3"
  config = {
    bucket  = "${var.aws_s3_prefix}-${var.aws_account}-terraform"
    key     = "aws_vpc.tfstate"
    region  = "${var.aws_region}"
  }
}

resource "aws_security_group_rule" "svc_ssh_ingress" {
  count                     = "${length(var.security_group_access)}"
  type                      = "ingress"
  from_port                 = "${var.security_group_default_ingress["from_port"]}"
  to_port                   = "${var.security_group_default_ingress["to_port"]}"
  protocol                  = "${var.security_group_default_ingress["protocol"]}"
  source_security_group_id  = "${module.svc.security_group_id}"
  security_group_id         = "${data.terraform_remote_state.aws_vpc.vpc.default_security_group_ids[element(var.security_group_access,
count.index)]}"
}

This is actually pretty cool about Terraform! When you deploy the Bastion service to the environment, rules are created to allow access. If you removed the service from the environment, those rules go away! Think of what you can actually do now with this. Rather than requesting access to other services and requiring security group rules to be tracked separately, or just opening up all traffic, a service can define its dependencies and explicitly what it needs to be able to talk to.

Final Words . . .

Environment segregation by AWS account, CloudTrail logging, and traffic access controls are three AWS best practices that will reinforce your environment’s security stance. Implementing them into your Terraform design following the guidance in this blog is an excellent way to ensure that you have a streamlined way of achieving your security goals and managing your infrastructure.

Note on Code Repositories

If you would like to see and use any of the code used this post, please consult the following:

 

For further info please check the below link

https://blog.threatstack.com/incorporating-aws-security-best-practices-into-terraform-design

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s