Repository: schubergphilis/terraform-aws-mcaf-security-group
Pull request #4: breaking: support multiple cidr blocks and updated README.md
Author: login: angautam
Labels: name: breaking
description: This change is not backwards compatible
Created: 2025-03-12T10:30:56Z | Merged: 2025-03-14T15:05:03Z

Add support for multiple CIDR blocks to be added in the security group egress and ingress rules.


Diff:
diff --git a/README.md b/README.md
index 53ce4e2..be2b329 100644
--- a/README.md
+++ b/README.md
@@ -35,8 +35,8 @@ No modules.
 | Name | Description | Type | Default | Required |
 |------|-------------|------|---------|:--------:|
 | <a name="input_description"></a> [description](#input\_description) | Description of the security group. | `string` | `null` | no |
-| <a name="input_egress_rules"></a> [egress\_rules](#input\_egress\_rules) | Security group egress rules. | <pre>map(object({<br/>    cidr_ipv4                    = optional(string)<br/>    cidr_ipv6                    = optional(string)<br/>    description                  = string<br/>    from_port                    = optional(number)<br/>    ip_protocol                  = optional(string, "-1")<br/>    prefix_list_id               = optional(string)<br/>    referenced_security_group_id = optional(string)<br/>    to_port                      = optional(number)<br/>  }))</pre> | `{}` | no |
-| <a name="input_ingress_rules"></a> [ingress\_rules](#input\_ingress\_rules) | Security group ingress rules. | <pre>map(object({<br/>    cidr_ipv4                    = optional(string)<br/>    cidr_ipv6                    = optional(string)<br/>    description                  = string<br/>    from_port                    = optional(number)<br/>    ip_protocol                  = optional(string, "-1")<br/>    prefix_list_id               = optional(string)<br/>    referenced_security_group_id = optional(string)<br/>    to_port                      = optional(number)<br/>  }))</pre> | `{}` | no |
+| <a name="input_egress_rules"></a> [egress\_rules](#input\_egress\_rules) | Security group egress rules. | <pre>map(object({<br/>    cidr_ipv4                    = optional(list(string))<br/>    cidr_ipv6                    = optional(list(string))<br/>    description                  = string<br/>    from_port                    = optional(number)<br/>    ip_protocol                  = optional(string, "-1")<br/>    prefix_list_id               = optional(string)<br/>    referenced_security_group_id = optional(string)<br/>    to_port                      = optional(number)<br/>  }))</pre> | `{}` | no |
+| <a name="input_ingress_rules"></a> [ingress\_rules](#input\_ingress\_rules) | Security group ingress rules. | <pre>map(object({<br/>    cidr_ipv4                    = optional(list(string))<br/>    cidr_ipv6                    = optional(list(string))<br/>    description                  = string<br/>    from_port                    = optional(number)<br/>    ip_protocol                  = optional(string, "-1")<br/>    prefix_list_id               = optional(string)<br/>    referenced_security_group_id = optional(string)<br/>    to_port                      = optional(number)<br/>  }))</pre> | `{}` | no |
 | <a name="input_name"></a> [name](#input\_name) | Name of the security group. Conflicts with `name_prefix`. | `string` | `null` | no |
 | <a name="input_name_prefix"></a> [name\_prefix](#input\_name\_prefix) | Name prefix of the security group. Conflicts with `name`. | `string` | `null` | no |
 | <a name="input_revoke_rules_on_delete"></a> [revoke\_rules\_on\_delete](#input\_revoke\_rules\_on\_delete) | Instruct Terraform to revoke all of the security group attached ingress and egress rules before deleting the group itself. | `bool` | `false` | no |
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 0000000..21b3973
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,24 @@
+# Upgrading Notes
+
+This document captures required refactoring on your part when upgrading to a module version that contains breaking changes.
+
+## Upgrading to v1.0.0
+
+### Key Changes
+
+- Introduced support for multiple CIDR blocks in both ingress and egress rules.
+- The `cidr_ipv4` and `cidr_ipv6` attributes have been updated from `string` to `list(string)` , allowing multiple CIDR blocks to be specified within `egress_rules` and `ingress_rules`.
+
+#### Variables
+
+The following variables have been modified:
+
+- `egress_rules`:
+    - `cidr_ipv4` attribute has changed from `string` to `list(string)`
+    - `cidr_ipv6` attribute has changed from `string` to `list(string)`
+    
+- `ingress_rules`:
+    - `cidr_ipv4` attribute has changed from `string` to `list(string)`
+    - `cidr_ipv6` attribute has changed from `string` to `list(string)`
+
+Ensure that your configurations are updated accordingly before applying changes. 🚀
\ No newline at end of file
diff --git a/examples/basic/main.tf b/examples/basic/main.tf
index ea10457..ce01a68 100644
--- a/examples/basic/main.tf
+++ b/examples/basic/main.tf
@@ -10,7 +10,7 @@ module "basic" {
 
   ingress_rules = {
     private_access = {
-      cidr_ipv4   = "10.64.0.0/12"
+      cidr_ipv4   = ["10.64.0.0/20", "10.67.0.0/20"]
       description = "Allow access from this range"
     }
   }
diff --git a/examples/security-group-id/main.tf b/examples/security-group-id/main.tf
new file mode 100644
index 0000000..974e3fc
--- /dev/null
+++ b/examples/security-group-id/main.tf
@@ -0,0 +1,31 @@
+provider "aws" {
+  region = "eu-west-1"
+}
+
+module "application_security_group" {
+  source = "../.."
+
+  description = "MyApplication security group"
+  name_prefix = "my-application-"
+
+  ingress_rules = {
+    private_access = {
+      cidr_ipv4   = ["10.73.0.0/20"]
+      description = "Allow access from private network range"
+    }
+  }
+}
+
+module "database_security_group" {
+  source = "../.."
+
+  description = "Security group for the database layer"
+  name_prefix = "database-"
+
+  ingress_rules = {
+    allow_security_group = {
+      referenced_security_group_id = module.application_security_group.id
+      description                  = "Allow access from the application security group"
+    }
+  }
+}
diff --git a/examples/security-group-id/terraform.tf b/examples/security-group-id/terraform.tf
new file mode 100644
index 0000000..a8cef01
--- /dev/null
+++ b/examples/security-group-id/terraform.tf
@@ -0,0 +1,10 @@
+terraform {
+  required_version = ">= 1.9"
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = ">= 5.0"
+    }
+  }
+}
diff --git a/main.tf b/main.tf
index 020e97e..efde0be 100644
--- a/main.tf
+++ b/main.tf
@@ -1,3 +1,61 @@
+# We use an intermediate list because a single rule with multiple CIDRs needs to expand into several objects.
+# Each expanded object gets a unique key by combining the original rule key with each individual CIDR.
+# Direct map creation wouldn't allow one input rule to generate multiple entries.
+# The list makes sure every object has the same structure with explicit cidr_ipv4 and cidr_ipv6 keys.
+#
+# Additionally, Terraform requires both outcomes of a conditional (?:) to return the same structure.
+# When a rule has CIDRs (cidr_ipv4 or cidr_ipv6), we use a for expression to create multiple objects,
+# each with a unique key ( "${key}-${cidr}") and explicit CIDR fields.
+# If a rule has no CIDRs, we still return an object with the same keys, but with null values.
+# This consistent schema meets Terraform's type requirements.
+
+locals {
+  egress_rules = { for rule in flatten([
+    for key, rule in var.egress_rules : (
+      rule.cidr_ipv4 != null || rule.cidr_ipv6 != null
+      ? [
+        for cidr in concat(
+          rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
+          rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
+          ) : merge(rule, {
+            key       = "${key}-${cidr}"
+            cidr_ipv4 = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
+            cidr_ipv6 = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
+        })
+      ]
+      : [
+        merge(rule, {
+          key       = key
+          cidr_ipv4 = null
+          cidr_ipv6 = null
+        })
+      ]
+    )
+  ]) : rule.key => rule }
+  ingress_rules = { for rule in flatten([
+    for key, rule in var.ingress_rules : (
+      rule.cidr_ipv4 != null || rule.cidr_ipv6 != null
+      ? [
+        for cidr in concat(
+          rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
+          rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
+          ) : merge(rule, {
+            key       = "${key}-${cidr}"
+            cidr_ipv4 = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
+            cidr_ipv6 = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
+        })
+      ]
+      : [
+        merge(rule, {
+          key       = key
+          cidr_ipv4 = null
+          cidr_ipv6 = null
+        })
+      ]
+    )
+  ]) : rule.key => rule }
+}
+
 resource "aws_security_group" "default" {
   #checkov:skip=CKV2_AWS_5: "Ensure that Security Groups are attached to another resource" - False positive.
 
@@ -19,7 +77,7 @@ resource "aws_security_group" "default" {
 }
 
 resource "aws_vpc_security_group_egress_rule" "default" {
-  for_each = var.egress_rules
+  for_each = local.egress_rules
 
   cidr_ipv4                    = each.value.cidr_ipv4
   cidr_ipv6                    = each.value.cidr_ipv6
@@ -34,7 +92,7 @@ resource "aws_vpc_security_group_egress_rule" "default" {
 }
 
 resource "aws_vpc_security_group_ingress_rule" "default" {
-  for_each = var.ingress_rules
+  for_each = local.ingress_rules
 
   cidr_ipv4                    = each.value.cidr_ipv4
   cidr_ipv6                    = each.value.cidr_ipv6
diff --git a/variables.tf b/variables.tf
index a6ec52d..fd2d790 100644
--- a/variables.tf
+++ b/variables.tf
@@ -6,8 +6,8 @@ variable "description" {
 
 variable "egress_rules" {
   type = map(object({
-    cidr_ipv4                    = optional(string)
-    cidr_ipv6                    = optional(string)
+    cidr_ipv4                    = optional(list(string))
+    cidr_ipv6                    = optional(list(string))
     description                  = string
     from_port                    = optional(number)
     ip_protocol                  = optional(string, "-1")
@@ -33,8 +33,8 @@ variable "egress_rules" {
 
 variable "ingress_rules" {
   type = map(object({
-    cidr_ipv4                    = optional(string)
-    cidr_ipv6                    = optional(string)
+    cidr_ipv4                    = optional(list(string))
+    cidr_ipv6                    = optional(list(string))
     description                  = string
     from_port                    = optional(number)
     ip_protocol                  = optional(string, "-1")


Reviews:
user: login: shoekstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:03:52Z
user: login: shoekstra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:36:31Z
user: login: noobnesz
state: DISMISSED
author_association: NONE
submitted_at: 2025-03-14T13:55:59Z
user: login: marwinbaumannsbp
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:40:34Z
user: login: marwinbaumannsbp
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:44:20Z

Review comments:
diff_hunk: @@ -1,3 +1,46 @@
+locals {
+  egress_rules = flatten([
+    for key, rule in var.egress_rules : flatten([
+      for type, cidrs in {
+        "ipv4" = rule.cidr_ipv4,
+        "ipv6" = rule.cidr_ipv6
+        } : cidrs != null ? [
+        for cidr in cidrs : {
+          key                          = "${key}-${cidr}"
+          description                  = rule.description
+          from_port                    = rule.from_port
+          ip_protocol                  = rule.ip_protocol
+          to_port                      = rule.to_port
+          cidr_ipv4                    = type == "ipv4" ? cidr : null
+          cidr_ipv6                    = type == "ipv6" ? cidr : null
+          prefix_list_id               = rule.prefix_list_id
+          referenced_security_group_id = rule.referenced_security_group_id
+        }
+      ] : []
+    ])
+  ])
path: main.tf
user: login: shoekstra
body: This can be simplified to `{ "${key}-${cidr}" => rule }` or something to that effect. Once it's a map, the for_each in the resources will also be easier.
created_at: 2025-03-12T13:03:52Z
author_association: MEMBER
diff_hunk: @@ -1,3 +1,40 @@
+locals {
+  egress_rules = merge([
+    for rule_key, rule in var.egress_rules : {
+      for cidr in concat(
+        rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
+        rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
+        ) : "${rule_key}-${cidr}" => {
+        description                  = rule.description
+        from_port                    = rule.from_port
+        ip_protocol                  = rule.ip_protocol
+        to_port                      = rule.to_port
+        cidr_ipv4                    = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
+        cidr_ipv6                    = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
+        prefix_list_id               = rule.prefix_list_id
+        referenced_security_group_id = rule.referenced_security_group_id
+      }
+    }
+  ]...)
+  ingress_rules = merge([
+    for rule_key, rule in var.ingress_rules : {
+      for cidr in concat(
+        rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
+        rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
+        ) : "${rule_key}-${cidr}" => {
+        description                  = rule.description
+        from_port                    = rule.from_port
+        ip_protocol                  = rule.ip_protocol
+        to_port                      = rule.to_port
+        cidr_ipv4                    = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
+        cidr_ipv6                    = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
+        prefix_list_id               = rule.prefix_list_id
+        referenced_security_group_id = rule.referenced_security_group_id
+      }
+    }
+  ]...)
path: main.tf
user: login: shoekstra
body: This could be more concise:

```suggestion
  egress_rules = merge([
    for rule_key, rule in var.egress_rules : {
      for cidr in concat(
        rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
        rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
        ) : "${rule_key}-${cidr}" => merge(rule, {
          cidr_ipv4 = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
          cidr_ipv6 = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
      })
    }
  ]...)
  ingress_rules = merge([
    for rule_key, rule in var.ingress_rules : {
      for cidr in concat(
        rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [],
        rule.cidr_ipv6 != null ? rule.cidr_ipv6 : []
        ) : "${rule_key}-${cidr}" => merge(rule, {
          cidr_ipv4 = contains(rule.cidr_ipv4 != null ? rule.cidr_ipv4 : [], cidr) ? cidr : null
          cidr_ipv6 = contains(rule.cidr_ipv6 != null ? rule.cidr_ipv6 : [], cidr) ? cidr : null
      })
    }
  ]...)
```
created_at: 2025-03-14T09:36:31Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-12T10:31:16Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️``
#### Terraform Lint 📖`success`
#### Terraform Validation 🤖`success`
<details><summary>Validation Output</summary>

```

Success! The configuration is valid.


```

</details>
author_association: CONTRIBUTOR