How to Terraform multiple security group with varying configuration
Tags: aws, security, terraform
Published: 2022-09-23 15:26:09

Recently I had to work on standardizing security configuration for some servers. These were created manually, as were all security groups associated with them.

We wanted to ensure that we knew exactly what ports were open for which server, and ported the configuration of the security groups to Terraform with a view to removing the old security groups and applying Terraform changes to create new ones.

This blog post explains how to create several security groups with varying configuration.

Firstly, I put all the configuration into variables.tf file :

```
variable "config" {
   default = {
    "server1" = {
       ports  = [
        {
          from = 3000
          to = 3000
          source="0.0.0.0/0"
        },
        {
          from = 3000
          to = 3000
          source="::/0"
        },
         {
          from = 25
          to = 25
          source="0.0.0.0/0"
        },
        {
          from = 587
          to = 587
          source ="0.0.0.0/0"
        },
        {
          from = 1433
          to = 1433
          source="sg-1234"
        },
        {       
          from = 0
          to = 65535
          source= "1.2.3.4/32"
        }
      ]
    },
     "server2" = {
      ports = [       
         {
          from = 2001
          to = 2001
          source="0.0.0.0/0"
        },
        {
          from = 2001
          to = 2001
          source="::/0"
        },
         {
          from = 24001
          to = 24001
          source="0.0.0.0/0"
        },
        {
          from = 24001
          to = 24001
          source="::/0"
        },
        {
          from = 1433
          to = 1433
          source="sg-1234"
        }
      ]
    }
     "server3" = {
        ports = null
    }, 
     "server4" = {
        ports = {
          from = 1433
          to = 1433
          source="sg-1234"
        },
     },
     "server5" = {
      ports = null
     }
   } 
 }
```

This variable contains ports/ranges that are open only on some instances.
The traffic source of port 1433 includes the ID of an already existing security group.

At the beginning I tried to create 2 types of elements in the map: one for ports and another one for the range of ports but then realised it's easier to put everything into one type of element.


By default, each server has ports 80 and 443 open to all traffic, and port 3389 (Remote Desktop) open from a specific IP.

After creating the variable with configuration for each server, I defined a security group for each server using Terraform [_for_each_ meta argument](https://www.terraform.io/language/meta-arguments/for_each). The name and tags of each security group created in this way contain the name of the server so that it's easily identifiable:

```
resource "aws_security_group" "server_access_sg" {
  for_each = var.config
  name = "${each.key}-sg"
  description = "The security group for ${each.key}"
  vpc_id = data.aws_vpc.default.id

  tags = {
    "Server" = "${each.key}"
    "Provider" = "Terraform"
  }
```

Within each of the security groups I also used in-line _ingress_ block to create security group rules:

```
ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "http"
    cidr_blocks = ["0.0.0.0/0", "::/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "https"
    cidr_blocks = ["0.0.0.0/0", "::/0"]
  }
```
Next, for each of the ports I created a dynamic ingress block using the [splat expression](https://www.terraform.io/language/expressions/splat), which is basically a simplified version of the _for_each_ loop.

```
dynamic "ingress" {
  for_each = each.value.ports[*]
  content {
    from_port   =  ingress.value.from
    to_port     =  ingress.value.to
    protocol    = "tcp"
    cidr_blocks = ingress.value.from != 1433 ? [ ingress.value.source] : null 
    ipv6_cidr_block = ingress.value.source=="::/0" ? [ingress.value.source] : null
    security_groups =   ingress.value.from == 1433 ? [ ingress.value.source] : null 
  }
}
```

Please note that this is a nested loop, and it's looping on the elements of _each.value_ element of the first loop. For example, all the configuration inside the squiggly brackets for server1 is the value.

```
"server1" = {
      ports = [...]
}
```
Because it's a nested loop, and _"each"_ is used to refer to the elements of the parent loop, in order to populate the values, we use "ingress".

Another thing worth pointing out is the conditional creation of _cidr_blocks/security_groups_ attributes. In this case security_groups argument needs to be created only when the rule for port 1433 is being defined. Therefore I set the attribute to null when one of the arguments is not needed.

Finally, I created an egress rule to allow all outgoing traffic for each security group:

```
egress {
    from_port   = 0
    to_port     = 0
    protocol    = -1
    cidr_blocks = ["0.0.0.0/0", "::/0"]
  } 
```

Happy learning, and if you have any suggestions on improving the code above, please feel free to leave a comment :)