From 218dfc727e619d9086bb26399f4bd1ec51de99a8 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 08:34:02 +0100 Subject: [PATCH 01/11] initial skeleton --- .../modules/security-group/README.md | 1 + .../modules/security-group/context.tf | 374 ++++++++++++++++++ infrastructure/modules/security-group/main.tf | 14 + 3 files changed, 389 insertions(+) create mode 100644 infrastructure/modules/security-group/README.md create mode 100644 infrastructure/modules/security-group/context.tf create mode 100644 infrastructure/modules/security-group/main.tf diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md new file mode 100644 index 00000000..c9fb5240 --- /dev/null +++ b/infrastructure/modules/security-group/README.md @@ -0,0 +1 @@ +# DAVEH diff --git a/infrastructure/modules/security-group/context.tf b/infrastructure/modules/security-group/context.tf new file mode 100644 index 00000000..e1afea79 --- /dev/null +++ b/infrastructure/modules/security-group/context.tf @@ -0,0 +1,374 @@ +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "../tags" + + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/security-group/main.tf b/infrastructure/modules/security-group/main.tf new file mode 100644 index 00000000..74146494 --- /dev/null +++ b/infrastructure/modules/security-group/main.tf @@ -0,0 +1,14 @@ +module "security_group" { + source = "terraform-aws-modules/security-group/aws" + + create = module.this.enabled + name = module.this.id + tags = module.this.tags + + description = null # DAVEH + + vpc_id = null # DAVEH + + egress_rules = {} # DAVEH + ingress_rules = {} # DAVEH +} From c68e0b67f2949ae95912151b7c751956d7238c3a Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 08:48:47 +0100 Subject: [PATCH 02/11] input variables --- infrastructure/modules/security-group/main.tf | 12 ++--- .../modules/security-group/variables.tf | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 infrastructure/modules/security-group/variables.tf diff --git a/infrastructure/modules/security-group/main.tf b/infrastructure/modules/security-group/main.tf index 74146494..9c1de78a 100644 --- a/infrastructure/modules/security-group/main.tf +++ b/infrastructure/modules/security-group/main.tf @@ -2,13 +2,13 @@ module "security_group" { source = "terraform-aws-modules/security-group/aws" create = module.this.enabled - name = module.this.id - tags = module.this.tags + name = module.this.id + tags = module.this.tags - description = null # DAVEH + description = var.description - vpc_id = null # DAVEH + vpc_id = var.vpc_id - egress_rules = {} # DAVEH - ingress_rules = {} # DAVEH + egress_rules = var.egress_rules + ingress_rules = var.ingress_rules } diff --git a/infrastructure/modules/security-group/variables.tf b/infrastructure/modules/security-group/variables.tf new file mode 100644 index 00000000..900333c0 --- /dev/null +++ b/infrastructure/modules/security-group/variables.tf @@ -0,0 +1,52 @@ +################################################################ +# Security group-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +variable "description" { + description = "Description for the security group" + type = string + default = null +} + +variable "vpc_id" { + description = "ID of the VPC where the security group is created" + type = string + default = null +} + +variable "egress_rules" { + description = "Map of egress rules to add to the security group" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(number) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(number) + })) + default = {} +} + +variable "ingress_rules" { + description = "Map of ingress rules to add to the security group" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(number) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(number) + })) + default = {} +} From 19929b17eb52981879f247e039fadc673127ecac Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 08:57:25 +0100 Subject: [PATCH 03/11] outputs --- .../modules/security-group/outputs.tf | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 infrastructure/modules/security-group/outputs.tf diff --git a/infrastructure/modules/security-group/outputs.tf b/infrastructure/modules/security-group/outputs.tf new file mode 100644 index 00000000..43671129 --- /dev/null +++ b/infrastructure/modules/security-group/outputs.tf @@ -0,0 +1,24 @@ +output "security_group_arn" { + description = "The ARN of the security group" + value = module.security_group.arn +} + +output "security_group_id" { + description = "The ID of the security group" + value = module.security_group.id +} + +output "security_group_name" { + description = "The name of the security group" + value = module.security_group.name +} + +output "security_group_owner_id" { + description = "The owner ID" + value = module.security_group.owner_id +} + +output "security_group_vpc_id" { + description = "The ID of the VPC used by the security group" + value = module.security_group.vpc_id +} From 9457d7f92a845170aba007ad60d15db9724b7d2f Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 09:09:38 +0100 Subject: [PATCH 04/11] autogen docs --- .../modules/security-group/README.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index c9fb5240..344d9b36 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -1 +1,76 @@ # DAVEH + + + + +## Requirements + +No requirements. + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [security\_group](#module\_security\_group) | terraform-aws-modules/security-group/aws | n/a | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [description](#input\_description) | Description for the security group | `string` | `null` | no | +| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [egress\_rules](#input\_egress\_rules) | Map of egress rules to add to the security group |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(number)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(number)
}))
| `{}` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [ingress\_rules](#input\_ingress\_rules) | Map of ingress rules to add to the security group |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(number)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(number)
}))
| `{}` | no | +| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [vpc\_id](#input\_vpc\_id) | ID of the VPC where the security group is created | `string` | `null` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [security\_group\_arn](#output\_security\_group\_arn) | The ARN of the security group | +| [security\_group\_id](#output\_security\_group\_id) | The ID of the security group | +| [security\_group\_name](#output\_security\_group\_name) | The name of the security group | +| [security\_group\_owner\_id](#output\_security\_group\_owner\_id) | The owner ID | +| [security\_group\_vpc\_id](#output\_security\_group\_vpc\_id) | The ID of the VPC used by the security group | + + + From 0b9851ff271a5914c6dd5df1ae9869a81a83bde6 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 11:08:00 +0100 Subject: [PATCH 05/11] draft remaining docs --- .../modules/security-group/README.md | 109 +++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index 344d9b36..0fe868a5 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -1,4 +1,111 @@ -# DAVEH +# Security-Group + +NHS Screening wrapper around the community +[`terraform-aws-modules/security-group/aws`][1] +module that consumes the shared `context.tf` for naming and tagging. + +[1]: https://registry.terraform.io/modules/terraform-aws-modules/security-group/aws/latest + +DAVEH: check docs for accuracy + +## What this module enforces + +| Control | How it is enforced | +| ---------------------------------- | ---------------------------------------------------------------------- | +| Consistent naming and tagging | `name = module.this.id` and `tags = module.this.tags` | +| Central `enabled` switch | `create = module.this.enabled` | +| VPC scoping | `vpc_id` is explicitly forwarded to the upstream module | +| Caller-defined traffic policy only | `ingress_rules` and `egress_rules` are passed through without mutation | + +## Usage + +### Minimal: create a security group in a VPC + +```hcl +module "app_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + + service = "bcss" + project = "api" + environment = "prod" + name = "app" + + description = "Security group for the screening API" + vpc_id = module.vpc.vpc_id +} +``` + +### Ingress from a load balancer security group + +```hcl +module "app_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + + service = "bcss" + project = "api" + environment = "prod" + name = "app" + + description = "Only allow HTTPS from the ALB" + vpc_id = module.vpc.vpc_id + + ingress_rules = { + alb_https = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + referenced_security_group_id = module.alb_sg.security_group_id + description = "HTTPS from ALB" + } + } +} +``` + +### Restrict egress to HTTPS only + +```hcl +module "app_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + + service = "bcss" + project = "api" + environment = "prod" + name = "app" + + description = "Limit outbound traffic to HTTPS" + vpc_id = module.vpc.vpc_id + + egress_rules = { + https_out = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv4 = "0.0.0.0/0" + description = "HTTPS egress only" + } + } +} +``` + +## Conventions + +* Keep `ingress_rules` and `egress_rules` keys stable (for example `alb_https`, + `db_5432`) so Terraform can track rule resources predictably over time. +* Prefer `referenced_security_group_id` over broad CIDR ranges when traffic is + between AWS-managed components. +* Set `description` to explain intent, not just protocol/port, so operators can + understand why a rule exists from the AWS console. +* Use `context.enabled = false` to disable creation in environments where the + security group is not required. + +## What this module does NOT do + +* Create or manage the VPC itself. Pass an existing VPC ID via `vpc_id`. +* Infer or inject platform-standard ingress/egress rules. All traffic policy is + caller-defined. +* Attach network ACLs, route tables, WAFs, or firewall policies. +* Manage references from compute resources (ECS services, Lambdas, RDS, etc.) + to this security group. Consumers must wire those associations directly. From d7a46263971554f3f5339f81503a4b3cb89105df Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 13:10:31 +0100 Subject: [PATCH 06/11] clarify what a missing vpc_id means --- infrastructure/modules/security-group/README.md | 3 ++- infrastructure/modules/security-group/variables.tf | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index 0fe868a5..77146a19 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -100,7 +100,8 @@ module "app_sg" { ## What this module does NOT do -* Create or manage the VPC itself. Pass an existing VPC ID via `vpc_id`. +* Create or manage the VPC itself. Pass an existing VPC ID via `vpc_id`, or + leave as the default `null` to use the region's default VPC. * Infer or inject platform-standard ingress/egress rules. All traffic policy is caller-defined. * Attach network ACLs, route tables, WAFs, or firewall policies. diff --git a/infrastructure/modules/security-group/variables.tf b/infrastructure/modules/security-group/variables.tf index 900333c0..30c48b55 100644 --- a/infrastructure/modules/security-group/variables.tf +++ b/infrastructure/modules/security-group/variables.tf @@ -12,7 +12,7 @@ variable "description" { } variable "vpc_id" { - description = "ID of the VPC where the security group is created" + description = "ID of the VPC where the security group is created; defaults to the region's default VPC" type = string default = null } From cad5c13c671710076f752052de7456a75107500b Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 13:23:52 +0100 Subject: [PATCH 07/11] set version --- infrastructure/modules/security-group/README.md | 1 - infrastructure/modules/security-group/main.tf | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index 77146a19..6e87fcb3 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -14,7 +14,6 @@ DAVEH: check docs for accuracy | ---------------------------------- | ---------------------------------------------------------------------- | | Consistent naming and tagging | `name = module.this.id` and `tags = module.this.tags` | | Central `enabled` switch | `create = module.this.enabled` | -| VPC scoping | `vpc_id` is explicitly forwarded to the upstream module | | Caller-defined traffic policy only | `ingress_rules` and `egress_rules` are passed through without mutation | ## Usage diff --git a/infrastructure/modules/security-group/main.tf b/infrastructure/modules/security-group/main.tf index 9c1de78a..ccec1e5f 100644 --- a/infrastructure/modules/security-group/main.tf +++ b/infrastructure/modules/security-group/main.tf @@ -1,5 +1,6 @@ module "security_group" { source = "terraform-aws-modules/security-group/aws" + version = "6.0.0" create = module.this.enabled name = module.this.id From db055305ec5c08cc682d4efe6777c45ea4d397eb Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 13:29:54 +0100 Subject: [PATCH 08/11] add example for `referenced_security_group_id = "self"` --- .../modules/security-group/README.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index 6e87fcb3..da162d98 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -34,6 +34,29 @@ module "app_sg" { } ``` +### Allow traffic between members of the security group + +```hcl +module "app_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + + service = "bcss" + project = "api" + environment = "prod" + name = "app" + + description = "Security group for the screening API" + vpc_id = module.vpc.vpc_id + + ingress_rules = { + self-all = { + ip_protocol = "-1" + referenced_security_group_id = "self" # rewritten to the security group's own id at apply time + description = "All traffic from members of this SG" + } + } +``` + ### Ingress from a load balancer security group ```hcl From 92f521a79283d911cb2187fed0a729ec329893ff Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 13:32:41 +0100 Subject: [PATCH 09/11] note on name suffix --- infrastructure/modules/security-group/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index da162d98..b892b74e 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -6,7 +6,9 @@ module that consumes the shared `context.tf` for naming and tagging. [1]: https://registry.terraform.io/modules/terraform-aws-modules/security-group/aws/latest -DAVEH: check docs for accuracy +The name of the security group will have a random suffix. This enables +replacements to happen without dropping traffic, as terraform can create +the replacement before destroying the original. ## What this module enforces From e6a183976b4b3460b0eb9369a5b61f312f2b34fb Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 10 Jun 2026 14:23:34 +0100 Subject: [PATCH 10/11] autoformat --- infrastructure/modules/security-group/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/security-group/main.tf b/infrastructure/modules/security-group/main.tf index ccec1e5f..8704d82a 100644 --- a/infrastructure/modules/security-group/main.tf +++ b/infrastructure/modules/security-group/main.tf @@ -1,5 +1,5 @@ module "security_group" { - source = "terraform-aws-modules/security-group/aws" + source = "terraform-aws-modules/security-group/aws" version = "6.0.0" create = module.this.enabled From 2e4d127e2016f92b9623393a7b3573e0b6a01991 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 01:20:04 +0100 Subject: [PATCH 11/11] feat(security-group): enhance module with exclusive rule enforcement, naming consistency, and new variables --- .github/dependabot.yaml | 1 + .gitleaksignore | 4 + .../security-group/.terraform.lock.hcl | 30 ++ .../modules/security-group/README.md | 279 +++++++++++++++++- .../modules/security-group/context.tf | 2 + .../modules/security-group/locals.tf | 6 + infrastructure/modules/security-group/main.tf | 31 +- .../modules/security-group/variables.tf | 33 ++- .../modules/security-group/versions.tf | 10 + infrastructure/modules/tags/main.tf | 2 +- scripts/config/gitleaks.toml | 27 +- 11 files changed, 399 insertions(+), 26 deletions(-) create mode 100644 infrastructure/modules/security-group/.terraform.lock.hcl create mode 100644 infrastructure/modules/security-group/locals.tf create mode 100644 infrastructure/modules/security-group/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 229165e4..1b6131a8 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -62,6 +62,7 @@ updates: - "infrastructure/modules/s3-bucket" - "infrastructure/modules/s3" - "infrastructure/modules/secrets-manager" + - "infrastructure/modules/security-group" - "infrastructure/modules/security-hub" - "infrastructure/modules/sns" - "infrastructure/modules/sqs" diff --git a/.gitleaksignore b/.gitleaksignore index f97f5c8a..bf9d628a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -5,3 +5,7 @@ e876843351a025eb754ec61982c8b7d95deeb709:.pre-commit-config.yaml:ipv4:119 e364bc1869c67729653c7efb4d6169f2294e68de:.pre-commit-config.yaml:ipv4:110 62088509f98ce02ce379adef2168b867eecfb5da:.pre-commit-config.yaml:ipv4:110 a3fa25da4e8f9eaa2e28c29f6196f23bfe87a58d:.pre-commit-config.yaml:ipv4:119 +# Historical false positive: example ARN comment in tags/main.tf contained hex-like content +# which triggered the ipv6 rule. Comment updated in later commit; old commits suppressed here. +7b49758d98757e8f404cb2c540c1f146afd6e395:infrastructure/modules/tags/main.tf:ipv6:131 +091dcd76884ffd307aee6c6b306b015c065f4896:infrastructure/modules/tags/main.tf:ipv6:131 diff --git a/infrastructure/modules/security-group/.terraform.lock.hcl b/infrastructure/modules/security-group/.terraform.lock.hcl new file mode 100644 index 00000000..e9cfc0a4 --- /dev/null +++ b/infrastructure/modules/security-group/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.29.0" + hashes = [ + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/security-group/README.md b/infrastructure/modules/security-group/README.md index b892b74e..0f4c93c2 100644 --- a/infrastructure/modules/security-group/README.md +++ b/infrastructure/modules/security-group/README.md @@ -12,11 +12,11 @@ the replacement before destroying the original. ## What this module enforces -| Control | How it is enforced | -| ---------------------------------- | ---------------------------------------------------------------------- | -| Consistent naming and tagging | `name = module.this.id` and `tags = module.this.tags` | -| Central `enabled` switch | `create = module.this.enabled` | -| Caller-defined traffic policy only | `ingress_rules` and `egress_rules` are passed through without mutation | +|Control|How it is enforced| +|---|---| +|Naming consistency|`name = module.this.id` and `tags = module.this.tags`| +|Creation gate|`create = module.this.enabled`| +|Exclusive rules|`enable_exclusive_rules = true` by default| ## Usage @@ -24,7 +24,7 @@ the replacement before destroying the original. ```hcl module "app_sg" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" service = "bcss" project = "api" @@ -40,7 +40,7 @@ module "app_sg" { ```hcl module "app_sg" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" service = "bcss" project = "api" @@ -63,7 +63,7 @@ module "app_sg" { ```hcl module "app_sg" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" service = "bcss" project = "api" @@ -89,7 +89,7 @@ module "app_sg" { ```hcl module "app_sg" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" service = "bcss" project = "api" @@ -111,6 +111,130 @@ module "app_sg" { } ``` +### IPv6 example: allow documentation-only ranges + +```hcl +module "ipv6_app_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "ipv6-app" + + description = "IPv6 example using documentation prefixes" + vpc_id = module.vpc.vpc_id + + ingress_rules = { + # RFC9637 documentation prefix example (3fff::/20) + app_from_docs_ipv6 = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv6 = "3fff:0f00:1234:5678::/64" + description = "HTTPS from a narrow RFC9637 documentation subnet" + } + } + + egress_rules = { + # RFC3849 documentation prefix example (2001:db8::/32) + app_to_docs_ipv6 = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + cidr_ipv6 = "2001:db8:abcd:ef01::/64" + description = "HTTPS egress to a narrow RFC3849 documentation range" + } + } +} +``` + +### Complex: database with multiple rule types (single IP, CIDR range, prefix list, security group) + +```hcl +module "database_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" + + service = "bcss" + project = "data" + environment = "prod" + name = "postgres-db" + + description = "PostgreSQL database with complex access rules" + vpc_id = module.vpc.vpc_id + + ingress_rules = { + # Single IP address (admin access) + admin_direct = { + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + # RFC5737 TEST-NET-3 example address (safe dummy value) + cidr_ipv4 = "203.0.113.5/32" + description = "Direct PostgreSQL from admin workstation" + } + + # Range of IPs (office network) + office_network = { + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + cidr_ipv4 = "10.1.0.0/16" + description = "PostgreSQL from office network" + } + + # AWS managed prefix list (e.g., S3 gateway endpoint) + s3_via_gateway = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + prefix_list_id = "pl-12345678" + description = "HTTPS to S3 via VPC gateway endpoint" + } + + # Referenced security group (app servers) + from_app_servers = { + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + referenced_security_group_id = module.app_sg.security_group_id + description = "PostgreSQL from application tier" + } + } + + egress_rules = { + # Outbound to external database replication + replication_out = { + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + # RFC5737 TEST-NET-1 example range (safe dummy value) + cidr_ipv4 = "192.0.2.0/24" + description = "PostgreSQL replication to standby" + } + + # DNS queries + dns_out = { + ip_protocol = "udp" + from_port = 53 + to_port = 53 + # 0.0.0.0/0 intentionally shown as "whole world" example traffic scope + cidr_ipv4 = "0.0.0.0/0" + description = "DNS resolution" + } + + # Egress to monitoring stack (referenced security group) + to_monitoring = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + referenced_security_group_id = module.monitoring_sg.security_group_id + description = "Metrics export to monitoring" + } + } +} +``` + ## Conventions * Keep `ingress_rules` and `egress_rules` keys stable (for example `alb_https`, @@ -122,6 +246,130 @@ module "app_sg" { * Use `context.enabled = false` to disable creation in environments where the security group is not required. +## Common rule presets (avoiding port duplication) + +To avoid duplicating port ranges and protocol definitions across multiple security groups, define common rule templates as locals in your Terraform stack. This keeps your code DRY and maintainable. + +### Reference: upstream module port ranges + +The upstream `terraform-aws-modules/security-group/aws` module provides comprehensive rule presets in its [`modules/`](https://github.com/terraform-aws-modules/terraform-aws-security-group/tree/master/modules) directory (e.g., `modules/http-80`, `modules/https-443`, `modules/mysql`, etc.). You can reference these for authoritative port ranges: + +1. Browse [`github.com/terraform-aws-modules/terraform-aws-security-group/tree/master/modules`](https://github.com/terraform-aws-modules/terraform-aws-security-group/tree/master/modules) +2. Check the relevant module (e.g., `modules/postgresql/main.tf`) to see the port definitions +3. Copy the port ranges and protocols into your stack's rule presets + +**Example:** To find the standard port for PostgreSQL, check [`modules/postgresql/main.tf`](https://github.com/terraform-aws-modules/terraform-aws-security-group/tree/master/modules/postgresql) in the upstream repo and extract the port number (5432 for TCP). + +### Defining and using rule presets in your stack + +In your consumer stack (e.g., `bcss` repository), define rule templates as locals: + +```hcl +locals { + # Common rule presets to reuse across security groups + # Reference: https://github.com/terraform-aws-modules/terraform-aws-security-group/tree/master/modules + rules_http = { + http = { + ip_protocol = "tcp" + from_port = 80 + to_port = 80 + description = "HTTP" + } + } + + rules_https = { + https = { + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + description = "HTTPS" + } + } + + rules_http_https = merge(local.rules_http, local.rules_https) + + rules_ssh = { + ssh = { + ip_protocol = "tcp" + from_port = 22 + to_port = 22 + description = "SSH" + } + } + + rules_dns = { + dns_tcp = { + ip_protocol = "tcp" + from_port = 53 + to_port = 53 + description = "DNS (TCP)" + } + dns_udp = { + ip_protocol = "udp" + from_port = 53 + to_port = 53 + description = "DNS (UDP)" + } + } + + rules_postgresql = { + postgresql = { + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + description = "PostgreSQL" + } + } + + rules_mysql = { + mysql = { + ip_protocol = "tcp" + from_port = 3306 + to_port = 3306 + description = "MySQL" + } + } +} +``` + +Then reference these locals when creating security groups: + +```hcl +module "web_sg" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-group?ref=" + + service = "bcss" + project = "web" + environment = "prod" + name = "web-tier" + + description = "Web tier with HTTP and HTTPS" + vpc_id = module.vpc.vpc_id + + # Merge preset rules with custom rules + ingress_rules = merge( + local.rules_http_https, + { + ssh_from_admin = { + ip_protocol = "tcp" + from_port = 22 + to_port = 22 + # RFC5737 TEST-NET-3 example range (safe dummy value) + cidr_ipv4 = "203.0.113.0/24" + description = "SSH from admin network" + } + } + ) + + egress_rules = merge( + local.rules_https, + local.rules_dns, + ) +} +``` + +This pattern keeps rule definitions in your own codebase where they can be versioned and reused across all your security groups. Store these locals in a shared file (e.g., `infrastructure/security_group_rules.tf`) so all your security group modules can reference them. + ## What this module does NOT do * Create or manage the VPC itself. Pass an existing VPC ID via `vpc_id`, or @@ -137,7 +385,10 @@ module "app_sg" { ## Requirements -No requirements. +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.5.7 | +| [aws](#requirement\_aws) | >= 6.29 | ## Providers @@ -147,7 +398,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | -| [security\_group](#module\_security\_group) | terraform-aws-modules/security-group/aws | n/a | +| [security\_group](#module\_security\_group) | terraform-aws-modules/security-group/aws | 6.0.0 | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -169,6 +420,7 @@ No resources. | [description](#input\_description) | Description for the security group | `string` | `null` | no | | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | | [egress\_rules](#input\_egress\_rules) | Map of egress rules to add to the security group |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(number)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(number)
}))
| `{}` | no | +| [enable\_exclusive\_rules](#input\_enable\_exclusive\_rules) | Whether to enforce that only the rules declared by this module exist on the security group. When true, out-of-band rules added via the AWS console or other Terraform configurations will be reverted on next apply | `bool` | `true` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | @@ -184,6 +436,8 @@ No resources. | [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | | [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [revoke\_rules\_on\_delete](#input\_revoke\_rules\_on\_delete) | Whether to revoke all rules on the security group when it is deleted. This is useful for security groups that are shared across multiple resources, as it prevents orphaned rules from remaining after the security group is deleted. | `bool` | `false` | no | +| [security\_group\_name](#input\_security\_group\_name) | Name of security group | `string` | `""` | no | | [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | | [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | @@ -191,7 +445,8 @@ No resources. | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | -| [vpc\_id](#input\_vpc\_id) | ID of the VPC where the security group is created | `string` | `null` | no | +| [use\_name\_prefix](#input\_use\_name\_prefix) | Whether to use the name (`name`) as a prefix, appending a random suffix | `bool` | `true` | no | +| [vpc\_id](#input\_vpc\_id) | ID of the VPC where the security group is created; defaults to the region's default VPC | `string` | `null` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/security-group/context.tf b/infrastructure/modules/security-group/context.tf index e1afea79..e934a84f 100644 --- a/infrastructure/modules/security-group/context.tf +++ b/infrastructure/modules/security-group/context.tf @@ -1,3 +1,4 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations # # ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags # All other instances of this file should be a copy of that one @@ -23,6 +24,7 @@ module "this" { source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/security-group/locals.tf b/infrastructure/modules/security-group/locals.tf new file mode 100644 index 00000000..871d93f4 --- /dev/null +++ b/infrastructure/modules/security-group/locals.tf @@ -0,0 +1,6 @@ +locals { + # Security group name is derived from context. The community module receives + # module.this.id directly so that context-driven label ordering is + # preserved without an intermediate local in the common case. + security_group_name = var.security_group_name != "" ? var.security_group_name : module.this.id +} diff --git a/infrastructure/modules/security-group/main.tf b/infrastructure/modules/security-group/main.tf index 8704d82a..7150966e 100644 --- a/infrastructure/modules/security-group/main.tf +++ b/infrastructure/modules/security-group/main.tf @@ -1,15 +1,34 @@ +################################################################ +# Security Group +# +# Thin NHS wrapper around the community security-group module that +# enforces the screening platform's baseline controls: +# +# * Exclusive rule enforcement: only rules defined in code +# (via enable_exclusive_rules = true) exist on the group +# * Naming: consistent via module.this.id with name prefix for +# safe replacements +# * Tagging: all resources tagged via module.this.tags +# * Creation gate: controlled via module.this.enabled +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + module "security_group" { source = "terraform-aws-modules/security-group/aws" version = "6.0.0" - create = module.this.enabled - name = module.this.id - tags = module.this.tags + create = module.this.enabled + name = local.security_group_name + use_name_prefix = var.use_name_prefix + description = var.description + enable_exclusive_rules = var.enable_exclusive_rules + revoke_rules_on_delete = var.revoke_rules_on_delete - description = var.description + egress_rules = var.egress_rules + ingress_rules = var.ingress_rules vpc_id = var.vpc_id - egress_rules = var.egress_rules - ingress_rules = var.ingress_rules + tags = module.this.tags } diff --git a/infrastructure/modules/security-group/variables.tf b/infrastructure/modules/security-group/variables.tf index 30c48b55..e79cc222 100644 --- a/infrastructure/modules/security-group/variables.tf +++ b/infrastructure/modules/security-group/variables.tf @@ -11,12 +11,11 @@ variable "description" { default = null } -variable "vpc_id" { - description = "ID of the VPC where the security group is created; defaults to the region's default VPC" - type = string - default = null +variable "enable_exclusive_rules" { + description = "Whether to enforce that only the rules declared by this module exist on the security group. When true, out-of-band rules added via the AWS console or other Terraform configurations will be reverted on next apply" + type = bool + default = true } - variable "egress_rules" { description = "Map of egress rules to add to the security group" type = map(object({ @@ -50,3 +49,27 @@ variable "ingress_rules" { })) default = {} } + +variable "revoke_rules_on_delete" { + description = "Whether to revoke all rules on the security group when it is deleted. This is useful for security groups that are shared across multiple resources, as it prevents orphaned rules from remaining after the security group is deleted." + type = bool + default = false +} + +variable "security_group_name" { + description = "Name of security group" + type = string + default = "" +} + +variable "use_name_prefix" { + description = "Whether to use the name (`name`) as a prefix, appending a random suffix" + type = bool + default = true +} + +variable "vpc_id" { + description = "ID of the VPC where the security group is created; defaults to the region's default VPC" + type = string + default = null +} diff --git a/infrastructure/modules/security-group/versions.tf b/infrastructure/modules/security-group/versions.tf new file mode 100644 index 00000000..39dd265c --- /dev/null +++ b/infrastructure/modules/security-group/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.29" + } + } +} diff --git a/infrastructure/modules/tags/main.tf b/infrastructure/modules/tags/main.tf index 1920d3a6..206a0fa1 100644 --- a/infrastructure/modules/tags/main.tf +++ b/infrastructure/modules/tags/main.tf @@ -128,7 +128,7 @@ locals { terraform_source = local.terraform_source # Strip the session name from the assumed-role ARN so the tag is stable # across plans regardless of the STS session identifier. - # e.g. arn:aws:sts::123:assumed-role/my-role/session -> arn:aws:sts::123:assumed-role/my-role + # e.g. arn:aws:sts::012345678901:assumed-role/my-role/session -> arn:aws:sts::012345678901:assumed-role/my-role deployed_by = replace(data.aws_iam_session_context.current.arn, "/\\/[^\\/]+$/", "") deployed_by_source = data.aws_iam_session_context.current.issuer_arn tool = var.tool diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index af5f0bb7..8371dcbc 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -11,8 +11,31 @@ regex = '''[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}''' [rules.allowlist] regexTarget = "match" regexes = [ - # Exclude the private network IPv4 addresses as well as the DNS servers for Google and OpenDNS - '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|208\.67\.222\.222|208\.67\.220\.220)''', + # Exclude private/reserved IPv4 addresses and well-known DNS servers used in docs/examples. + # Includes RFC5737 TEST-NET ranges: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|192\.0\.2\.[0-9]{1,3}|198\.51\.100\.[0-9]{1,3}|203\.0\.113\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1|1\.0\.0\.1)''', +] + +[[rules]] +description = "IPv6" +id = "ipv6" +# Matches valid IPv6 forms requiring at least 2 groups on each side of :: to +# avoid false positives from AWS ARNs (which use :: between region and account). +# full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +# compressed: 2001:db8::1, fe80:db8::1 +# trailing :: fe80:db8:: (2+ groups required before ::) +# leading :: ::db8:1 (2+ groups required after ::) +# Note: RE2 does not support lookahead/lookbehind so boundary enforcement is +# achieved structurally via minimum repetition counts. +regex = '''(?i)(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){2,7}:|(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}|(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}|(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}|(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:(?::[0-9a-f]{1,4}){1,6}|:(?::[0-9a-f]{1,4}){2,7}''' + +[rules.allowlist] +regexTarget = "match" +regexes = [ + # Exclude IPv6 documentation prefixes used in examples. + # RFC3849: 2001:db8::/32 + # RFC9637: 3fff::/20 (3fff:0000:: to 3fff:0fff::) + '''(?i)(^|[^0-9a-f])(2001:db8:|3fff:0[0-9a-f]{0,3}:)''', ] [allowlist]