From c3492a8c1e0e77da7a2bb065ff34ada39760f1df Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Mon, 1 Jun 2026 09:23:00 +0100 Subject: [PATCH 1/7] BCSS-23407 create TERRAFORM MODULE: R53 --- infrastructure/modules/r53/README.md | 176 ++++++++++++ infrastructure/modules/r53/context.tf | 365 ++++++++++++++++++++++++ infrastructure/modules/r53/main.tf | 98 +++++++ infrastructure/modules/r53/outputs.tf | 94 ++++++ infrastructure/modules/r53/variables.tf | 152 ++++++++++ infrastructure/modules/r53/versions.tf | 10 + 6 files changed, 895 insertions(+) create mode 100644 infrastructure/modules/r53/README.md create mode 100644 infrastructure/modules/r53/context.tf create mode 100644 infrastructure/modules/r53/main.tf create mode 100644 infrastructure/modules/r53/outputs.tf create mode 100644 infrastructure/modules/r53/variables.tf create mode 100644 infrastructure/modules/r53/versions.tf diff --git a/infrastructure/modules/r53/README.md b/infrastructure/modules/r53/README.md new file mode 100644 index 00000000..dbf695b3 --- /dev/null +++ b/infrastructure/modules/r53/README.md @@ -0,0 +1,176 @@ +# r53 + +Creates Route53 hosted zones, Route53 Resolver endpoints, and Route53 +Resolver DNS Firewall rule groups for screening shared-resource stacks. +This is a thin screening wrapper around the community +[`terraform-aws-modules/route53/aws`](https://registry.terraform.io/modules/terraform-aws-modules/route53/aws/latest) +module set, with naming and tagging supplied by the central `tags` module via +`context.tf`. + +## Usage + +### Private hosted zone with records and cross-account authorization + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + hosted_zones = { + internal = { + name = "screening.internal" + private_zone = true + ignore_vpc = true + + vpc = { + primary = { + vpc_id = module.vpc.vpc_id + vpc_region = "eu-west-2" + } + } + + vpc_association_authorizations = { + shared-services = { + vpc_id = "vpc-0123456789abcdef0" + vpc_region = "eu-west-2" + } + } + + records = { + api = { + type = "A" + alias = { + name = module.alb.dns_name + zone_id = module.alb.zone_id + } + } + } + } + } +} +``` + +### Inbound and outbound resolver endpoints + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_endpoints = { + inbound = { + direction = "INBOUND" + type = "IPV4" + protocols = ["Do53"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + security_group_ingress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow inbound DNS queries from subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow inbound DNS queries from subnet B" + } + } + + security_group_egress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow DNS responses to subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow DNS responses to subnet B" + } + } + } + + outbound = { + direction = "OUTBOUND" + type = "IPV4" + protocols = ["Do53", "DoH"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + rules = { + onprem = { + domain_name = "corp.internal." + rule_type = "FORWARD" + vpc_id = module.vpc.vpc_id + target_ip = [ + { ip = "10.20.0.10" }, + { ip = "10.20.0.11" }, + ] + } + } + } + } +} +``` + +### Resolver DNS Firewall rule group + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_firewall_rule_groups = { + default = { + rules = { + block-malware = { + priority = 100 + action = "BLOCK" + block_response = "NODATA" + domains = ["bad.example.", "malware.example."] + } + allow-aws = { + priority = 110 + action = "ALLOW" + domains = ["amazonaws.com.", "amazon.com."] + } + } + } + } +} +``` + +## Conventions + +- Hosted zone names are caller-supplied DNS names; the wrapper does not derive + them from `context.tf`. +- Resolver endpoint and firewall rule group names default to context-derived, + deterministic names based on the item key. +- All resources inherit the standard screening tag set, and per-item `tags` + are merged on top. +- Resolver endpoint resources default to `var.aws_region` when no per-item + `region` is supplied. + +## What this module does NOT do + +- Create `aws_route53_zone_association` resources for VPCs in other accounts. + As with the upstream module, only + `aws_route53_vpc_association_authorization` is handled here. +- Create `aws_route53_resolver_firewall_rule_group_association` resources. + Associate firewall rule groups to VPCs in the consumer stack once the shared + resource exists. +- Abstract every Route53 feature into separate screening opinions. This module + stays deliberately thin and forwards most behavior to the upstream module. + + + + + + + diff --git a/infrastructure/modules/r53/context.tf b/infrastructure/modules/r53/context.tf new file mode 100644 index 00000000..34ff6b16 --- /dev/null +++ b/infrastructure/modules/r53/context.tf @@ -0,0 +1,365 @@ +# +# 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 = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + + 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 = {} + 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 this module path." +} + +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/r53/main.tf b/infrastructure/modules/r53/main.tf new file mode 100644 index 00000000..89487e49 --- /dev/null +++ b/infrastructure/modules/r53/main.tf @@ -0,0 +1,98 @@ +################################################################ +# Route53 Module +# +# Screening wrapper around the community +# `terraform-aws-modules/route53/aws` modules: +# - root module -> hosted zones, records, DNSSEC +# - modules/resolver-endpoint -> Route53 Resolver endpoints and rules +# - modules/resolver-firewall-rule-group -> Resolver DNS Firewall rule groups +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +module "resolver_endpoint_label" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + for_each = var.resolver_endpoints + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-endpoint", each.key]) +} + +module "resolver_firewall_rule_group_label" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + for_each = var.resolver_firewall_rule_groups + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-firewall", each.key]) +} + +module "hosted_zones" { + source = "terraform-aws-modules/route53/aws" + version = "6.5.0" + + for_each = module.this.enabled ? var.hosted_zones : {} + + comment = each.value.comment + create = each.value.create + create_dnssec_kms_key = each.value.create_dnssec_kms_key + create_zone = each.value.create_zone + delegation_set_id = each.value.delegation_set_id + dnssec_key_signing_key_name = each.value.dnssec_key_signing_key_name + dnssec_kms_key_aliases = each.value.dnssec_kms_key_aliases + dnssec_kms_key_arn = each.value.dnssec_kms_key_arn + dnssec_kms_key_description = each.value.dnssec_kms_key_description + dnssec_kms_key_tags = merge(module.this.tags, each.value.dnssec_kms_key_tags) + enable_accelerated_recovery = each.value.enable_accelerated_recovery + enable_dnssec = each.value.enable_dnssec + force_destroy = each.value.force_destroy + ignore_vpc = each.value.ignore_vpc + name = each.value.name + private_zone = each.value.private_zone + records = each.value.records + tags = merge(module.this.tags, each.value.tags) + timeouts = each.value.timeouts + vpc = each.value.vpc + vpc_association_authorizations = each.value.vpc_association_authorizations + vpc_id = each.value.vpc_id +} + +module "resolver_endpoints" { + source = "terraform-aws-modules/route53/aws//modules/resolver-endpoint" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_endpoints : {} + + create = each.value.create + direction = each.value.direction + ip_address = each.value.ip_address + name = coalesce(each.value.name, module.resolver_endpoint_label[each.key].id) + protocols = each.value.protocols + region = coalesce(each.value.region, var.aws_region) + rules = each.value.rules + tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.tags) + type = each.value.type + + create_security_group = each.value.create_security_group + security_group_description = each.value.security_group_description + security_group_egress_rules = each.value.security_group_egress_rules + security_group_ids = each.value.security_group_ids + security_group_ingress_rules = each.value.security_group_ingress_rules + security_group_name = coalesce(each.value.security_group_name, module.resolver_endpoint_label[each.key].id) + security_group_tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.security_group_tags) + security_group_use_name_prefix = each.value.security_group_use_name_prefix + vpc_id = each.value.vpc_id +} + +module "resolver_firewall_rule_groups" { + source = "terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_firewall_rule_groups : {} + + create = each.value.create + name = coalesce(each.value.name, module.resolver_firewall_rule_group_label[each.key].id) + ram_resource_associations = each.value.ram_resource_associations + region = coalesce(each.value.region, var.aws_region) + rules = each.value.rules + tags = merge(module.resolver_firewall_rule_group_label[each.key].tags, each.value.tags) +} diff --git a/infrastructure/modules/r53/outputs.tf b/infrastructure/modules/r53/outputs.tf new file mode 100644 index 00000000..ea73b874 --- /dev/null +++ b/infrastructure/modules/r53/outputs.tf @@ -0,0 +1,94 @@ +output "hosted_zone_ids" { + description = "Map of hosted zone key -> Route53 hosted zone ID." + value = { for key, zone in module.hosted_zones : key => zone.id } +} + +output "hosted_zone_arns" { + description = "Map of hosted zone key -> Route53 hosted zone ARN." + value = { for key, zone in module.hosted_zones : key => zone.arn } +} + +output "hosted_zone_names" { + description = "Map of hosted zone key -> Route53 hosted zone name." + value = { for key, zone in module.hosted_zones : key => zone.name } +} + +output "hosted_zone_name_servers" { + description = "Map of hosted zone key -> authoritative name servers." + value = { for key, zone in module.hosted_zones : key => zone.name_servers } +} + +output "hosted_zone_records" { + description = "Map of hosted zone key -> records created in the zone." + value = { for key, zone in module.hosted_zones : key => zone.records } +} + +output "resolver_endpoint_ids" { + description = "Map of resolver endpoint key -> endpoint ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.id } +} + +output "resolver_endpoint_arns" { + description = "Map of resolver endpoint key -> endpoint ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.arn } +} + +output "resolver_endpoint_host_vpc_ids" { + description = "Map of resolver endpoint key -> host VPC ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.host_vpc_id } +} + +output "resolver_endpoint_security_group_ids" { + description = "Map of resolver endpoint key -> attached security group IDs." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_ids } +} + +output "resolver_endpoint_security_group_arns" { + description = "Map of resolver endpoint key -> created security group ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_arn } +} + +output "resolver_endpoint_created_security_group_ids" { + description = "Map of resolver endpoint key -> created security group ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_id } +} + +output "resolver_endpoint_ip_addresses" { + description = "Map of resolver endpoint key -> endpoint IP addresses." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.ip_addresses } +} + +output "resolver_endpoint_rules" { + description = "Map of resolver endpoint key -> resolver rules created by that endpoint module." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.rules } +} + +output "resolver_firewall_rule_group_ids" { + description = "Map of firewall rule group key -> rule group ID." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.id } +} + +output "resolver_firewall_rule_group_arns" { + description = "Map of firewall rule group key -> rule group ARN." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.arn } +} + +output "resolver_firewall_rule_group_share_statuses" { + description = "Map of firewall rule group key -> RAM share status." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.share_status } +} + +output "resolver_firewall_rule_group_domain_lists" { + description = "Map of firewall rule group key -> domain lists created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.domain_lists } +} + +output "resolver_firewall_rule_group_rules" { + description = "Map of firewall rule group key -> firewall rules created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.rules } +} + +output "resolver_firewall_rule_group_ram_resource_associations" { + description = "Map of firewall rule group key -> RAM resource associations created for that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.ram_resource_associations } +} diff --git a/infrastructure/modules/r53/variables.tf b/infrastructure/modules/r53/variables.tf new file mode 100644 index 00000000..e83dbf8d --- /dev/null +++ b/infrastructure/modules/r53/variables.tf @@ -0,0 +1,152 @@ +################################################################ +# Route53-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# `context.tf` via `module.this`. +################################################################ + +variable "hosted_zones" { + description = <<-EOT + Map of hosted zones to create or adopt. + + Each key is a logical identifier used only in Terraform state and outputs. + Each value forwards to the upstream `terraform-aws-modules/route53/aws` + root module. + + `name` is the DNS zone name (for example `example.internal` or + `example.nhs.uk`) and is required. + EOT + + type = map(object({ + name = string + create = optional(bool, true) + create_zone = optional(bool, true) + private_zone = optional(bool, false) + vpc_id = optional(string) + comment = optional(string) + delegation_set_id = optional(string) + force_destroy = optional(bool) + enable_accelerated_recovery = optional(bool) + ignore_vpc = optional(bool, false) + vpc = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + vpc_association_authorizations = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + enable_dnssec = optional(bool, false) + create_dnssec_kms_key = optional(bool, true) + dnssec_kms_key_arn = optional(string) + dnssec_kms_key_description = optional(string) + dnssec_kms_key_aliases = optional(list(string), []) + dnssec_kms_key_tags = optional(map(string), {}) + dnssec_key_signing_key_name = optional(string) + records = optional(any, {}) + tags = optional(map(string), {}) + timeouts = optional(object({ + create = optional(string) + update = optional(string) + delete = optional(string) + })) + })) + default = {} +} + +variable "resolver_endpoints" { + description = <<-EOT + Map of Route53 Resolver endpoints to create. + + Each key is a logical identifier used in Terraform state and outputs. + Names default to a context-derived value when `name` is omitted. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + direction = optional(string, "INBOUND") + type = optional(string) + protocols = optional(list(string), []) + ip_address = optional(list(object({ + ip = optional(string) + ipv6 = optional(string) + subnet_id = string + })), []) + security_group_ids = optional(list(string), []) + create_security_group = optional(bool, true) + security_group_name = optional(string) + security_group_use_name_prefix = optional(bool, false) + security_group_description = optional(string) + vpc_id = optional(string) + security_group_ingress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_egress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_tags = optional(map(string), {}) + rules = optional(map(object({ + domain_name = string + name = optional(string) + rule_type = string + tags = optional(map(string), {}) + target_ip = optional(list(object({ + ip = string + ipv6 = optional(string) + port = optional(number) + protocol = optional(string) + }))) + vpc_id = optional(string) + })), {}) + tags = optional(map(string), {}) + })) + default = {} +} + +variable "resolver_firewall_rule_groups" { + description = <<-EOT + Map of Route53 Resolver DNS Firewall rule groups to create. + + Each rule can either create a dedicated firewall domain list via `domains` + or reference an existing one via `firewall_domain_list_id`. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + ram_resource_associations = optional(map(object({ + resource_share_arn = string + })), {}) + rules = optional(map(object({ + name = optional(string) + domains = optional(list(string)) + action = string + block_override_dns_type = optional(string) + block_override_domain = optional(string) + block_override_ttl = optional(number) + block_response = optional(string) + firewall_domain_list_id = optional(string) + firewall_domain_redirection_action = optional(string) + priority = number + q_type = optional(string) + tags = optional(map(string), {}) + })), {}) + tags = optional(map(string), {}) + })) + default = {} +} diff --git a/infrastructure/modules/r53/versions.tf b/infrastructure/modules/r53/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/r53/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} From c7355c53107b5b933b39e554888fb05c461dc17d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:28:07 +0100 Subject: [PATCH 2/7] feat: refactor R53 module to use relative source paths and enhance protocol validation N.B. This is a copy taken from the 'shared' stack implementation, and has been pushed without validation checks --- infrastructure/modules/r53/context.tf | 20 +++- infrastructure/modules/r53/main.tf | 141 +++++++++++++++++++++++- infrastructure/modules/r53/variables.tf | 14 ++- 3 files changed, 165 insertions(+), 10 deletions(-) diff --git a/infrastructure/modules/r53/context.tf b/infrastructure/modules/r53/context.tf index 34ff6b16..c28646f8 100644 --- a/infrastructure/modules/r53/context.tf +++ b/infrastructure/modules/r53/context.tf @@ -21,8 +21,9 @@ # module "this" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region @@ -80,7 +81,14 @@ variable "context" { label_value_case = null terraform_source = null descriptor_formats = {} - labels_as_tags = ["unset"] + # 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. @@ -104,7 +112,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { @@ -254,6 +262,7 @@ variable "label_key_case" { 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`." @@ -271,6 +280,7 @@ variable "label_value_case" { 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`." @@ -293,7 +303,7 @@ variable "descriptor_formats" { 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 + EOT } variable "owner" { @@ -328,6 +338,7 @@ variable "data_type" { } } + variable "public_facing" { type = bool description = "Whether this resource is public facing" @@ -343,7 +354,6 @@ variable "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" diff --git a/infrastructure/modules/r53/main.tf b/infrastructure/modules/r53/main.tf index 89487e49..db1a8f6c 100644 --- a/infrastructure/modules/r53/main.tf +++ b/infrastructure/modules/r53/main.tf @@ -11,7 +11,7 @@ ################################################################ module "resolver_endpoint_label" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + source = "../tags" for_each = var.resolver_endpoints context = module.this.context @@ -19,13 +19,22 @@ module "resolver_endpoint_label" { } module "resolver_firewall_rule_group_label" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + source = "../tags" for_each = var.resolver_firewall_rule_groups context = module.this.context attributes = concat(module.this.attributes, ["resolver-firewall", each.key]) } +module "resolver_firewall_association_label" { + source = "../tags" + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-fw-assoc", each.key]) + id_length_limit = 64 +} + module "hosted_zones" { source = "terraform-aws-modules/route53/aws" version = "6.5.0" @@ -66,7 +75,7 @@ module "resolver_endpoints" { direction = each.value.direction ip_address = each.value.ip_address name = coalesce(each.value.name, module.resolver_endpoint_label[each.key].id) - protocols = each.value.protocols + protocols = length(each.value.protocols) > 0 ? each.value.protocols : ["Do53"] region = coalesce(each.value.region, var.aws_region) rules = each.value.rules tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.tags) @@ -83,6 +92,79 @@ module "resolver_endpoints" { vpc_id = each.value.vpc_id } +################################################################ +# Firewall Rule Groups +# +# UPSTREAM DEFECT (terraform-aws-modules/route53/aws v6.5.0) +# ────────────────────────────────────────────────────────── +# The community resolver-firewall-rule-group submodule creates +# an `aws_route53_resolver_firewall_domain_list` for EVERY rule +# in the `rules` map, regardless of whether a rule already +# supplies its own `firewall_domain_list_id` (e.g. an AWS- +# managed threat list or a RAM-shared domain list). +# +# In the rule resource it uses: +# firewall_domain_list_id = try( +# coalesce(each.value.firewall_domain_list_id, +# aws_route53_resolver_firewall_domain_list.this[each.key].id), +# null) +# +# So the provided ID takes precedence, but the duplicate +# customer-owned domain list is still created as an empty +# orphan. This wastes resources and causes confusion in the +# console (e.g. 4 empty customer-owned lists alongside 4 AWS- +# managed lists for the same threat categories). +# +# WORKAROUND +# ────────── +# We split each rule group's rules into two sets: +# +# 1. "domain" rules – rules where `domains` is populated and +# `firewall_domain_list_id` is null. These are passed to +# the community module which correctly creates a domain +# list and wires it to the rule. +# +# 2. "external" rules – rules where `firewall_domain_list_id` +# is set (AWS-managed lists, RAM-shared lists, or any +# pre-existing list). These bypass the community module +# entirely and are created as standalone +# `aws_route53_resolver_firewall_rule` resources attached +# to the same rule group. +# +# REVERT INSTRUCTIONS (when upstream is fixed) +# ───────────────────────────────────────────── +# Once the community module conditionally creates domain lists +# only for rules that do NOT supply `firewall_domain_list_id`: +# +# 1. Remove the `firewall_domain_rules` and +# `firewall_external_rules` locals below. +# 2. Remove the `aws_route53_resolver_firewall_rule.external` +# resource block. +# 3. Change `module.resolver_firewall_rule_groups` to pass +# `rules = each.value.rules` instead of +# `rules = local.firewall_domain_rules[each.key]`. +# 4. Run `terraform plan` to confirm the external rules are +# adopted by the community module with no diff. +################################################################ + +locals { + # Rules that create their own domain list (passed to community module) + firewall_domain_rules = { + for gk, g in var.resolver_firewall_rule_groups : gk => { + for rk, r in g.rules : rk => r + if r.firewall_domain_list_id == null + } + } + + # Rules that reference an existing domain list (standalone resources) + firewall_external_rules = merge([ + for gk, g in var.resolver_firewall_rule_groups : { + for rk, r in g.rules : "${gk}/${rk}" => merge(r, { group_key = gk }) + if r.firewall_domain_list_id != null + } + ]...) +} + module "resolver_firewall_rule_groups" { source = "terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group" version = "6.5.0" @@ -93,6 +175,57 @@ module "resolver_firewall_rule_groups" { name = coalesce(each.value.name, module.resolver_firewall_rule_group_label[each.key].id) ram_resource_associations = each.value.ram_resource_associations region = coalesce(each.value.region, var.aws_region) - rules = each.value.rules + rules = local.firewall_domain_rules[each.key] tags = merge(module.resolver_firewall_rule_group_label[each.key].tags, each.value.tags) } + +# Standalone rules for existing/AWS-managed domain lists. +# These bypass the community module to avoid orphan domain list +# creation (see UPSTREAM DEFECT comment above). +# REVERT: Remove this resource block once the upstream fix lands. +resource "aws_route53_resolver_firewall_rule" "external" { + for_each = module.this.enabled ? local.firewall_external_rules : {} + + action = each.value.action + block_override_dns_type = each.value.block_override_dns_type + block_override_domain = each.value.block_override_domain + block_override_ttl = each.value.block_override_ttl + block_response = each.value.block_response + firewall_domain_list_id = each.value.firewall_domain_list_id + firewall_domain_redirection_action = each.value.firewall_domain_redirection_action + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + name = coalesce(each.value.name, element(split("/", each.key), 1)) + priority = each.value.priority + q_type = each.value.q_type +} + +################################################################ +# Firewall Rule Group VPC Associations +# +# The community module does not create VPC associations, so we +# create them here. +################################################################ + +locals { + firewall_vpc_associations = merge([ + for group_key, group in var.resolver_firewall_rule_groups : { + for vpc_key, vpc_id in group.vpc_ids : + "${group_key}-${vpc_key}" => { + group_key = group_key + vpc_id = vpc_id + priority = group.priority + } + } + ]...) +} + +resource "aws_route53_resolver_firewall_rule_group_association" "this" { + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + name = module.resolver_firewall_association_label[each.key].id + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + vpc_id = each.value.vpc_id + priority = each.value.priority + + tags = module.resolver_firewall_association_label[each.key].tags +} diff --git a/infrastructure/modules/r53/variables.tf b/infrastructure/modules/r53/variables.tf index e83dbf8d..a8c43749 100644 --- a/infrastructure/modules/r53/variables.tf +++ b/infrastructure/modules/r53/variables.tf @@ -68,7 +68,7 @@ variable "resolver_endpoints" { name = optional(string) direction = optional(string, "INBOUND") type = optional(string) - protocols = optional(list(string), []) + protocols = optional(list(string), ["Do53"]) ip_address = optional(list(object({ ip = optional(string) ipv6 = optional(string) @@ -115,6 +115,16 @@ variable "resolver_endpoints" { tags = optional(map(string), {}) })) default = {} + + validation { + condition = alltrue([ + for key, ep in var.resolver_endpoints : + alltrue([ + for p in ep.protocols : contains(["Do53", "DoH", "DoH-FIPS"], p) + ]) + ]) + error_message = "Each protocol value must be one of: Do53, DoH, DoH-FIPS. An empty list defaults to Do53." + } } variable "resolver_firewall_rule_groups" { @@ -132,6 +142,8 @@ variable "resolver_firewall_rule_groups" { ram_resource_associations = optional(map(object({ resource_share_arn = string })), {}) + vpc_ids = optional(map(string), {}) + priority = optional(number, 100) rules = optional(map(object({ name = optional(string) domains = optional(list(string)) From d135a848436892eebca97775f331450a00537b93 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:35:15 +0100 Subject: [PATCH 3/7] refactor: update R53 module context to ignore tflint for pinned source --- infrastructure/modules/r53/context.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/modules/r53/context.tf b/infrastructure/modules/r53/context.tf index c28646f8..9a4652b0 100644 --- a/infrastructure/modules/r53/context.tf +++ b/infrastructure/modules/r53/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 @@ -21,6 +22,7 @@ # module "this" { + # tflint-ignore: terraform_module_pinned_source source = "../tags" enabled = var.enabled From 60202c6ff38ea9ee2c6deea73cd51d7b463277ab Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:41:31 +0100 Subject: [PATCH 4/7] feat: add .terraform.lock.hcl for R53 module with AWS provider configuration --- .../modules/r53/.terraform.lock.hcl | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 infrastructure/modules/r53/.terraform.lock.hcl diff --git a/infrastructure/modules/r53/.terraform.lock.hcl b/infrastructure/modules/r53/.terraform.lock.hcl new file mode 100644 index 00000000..10c97fd0 --- /dev/null +++ b/infrastructure/modules/r53/.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.0.0, >= 6.14.0, >= 6.28.0, >= 6.42.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", + ] +} From bfad265e8c803a3b7a971b0c96b68f41299dfcd0 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:48:02 +0100 Subject: [PATCH 5/7] feat(r53): extract locals and add enforcement docs Move firewall rule and association locals from main.tf into locals.tf to follow module file conventions. Add a "What this module enforces" section to the r53 README. Include regenerated .github/dependabot.yaml from pre-commit automation. --- .github/dependabot.yaml | 1 + infrastructure/modules/r53/README.md | 103 +++++++++++++++++++++++++++ infrastructure/modules/r53/locals.tf | 42 +++++++++++ infrastructure/modules/r53/main.tf | 33 +-------- 4 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 infrastructure/modules/r53/locals.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 02b50d0a..02f2917a 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -54,6 +54,7 @@ updates: - "infrastructure/modules/license-manager" - "infrastructure/modules/parameter_store" - "infrastructure/modules/r53-healthcheck" + - "infrastructure/modules/r53" - "infrastructure/modules/rds-database" - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" diff --git a/infrastructure/modules/r53/README.md b/infrastructure/modules/r53/README.md index dbf695b3..882d3d59 100644 --- a/infrastructure/modules/r53/README.md +++ b/infrastructure/modules/r53/README.md @@ -7,6 +7,15 @@ This is a thin screening wrapper around the community module set, with naming and tagging supplied by the central `tags` module via `context.tf`. +## What this module enforces + +|Control|How it is enforced| +|-|-| +|Creation gate|All `for_each` resource maps are set to `{}` when `module.this.enabled = false`, preventing any resource creation| +|Context-derived naming and tagging|All resources inherit `module.this.tags`; resolver endpoint and firewall rule group names default to context-derived values| +|Firewall domain list de-duplication|Standalone `aws_route53_resolver_firewall_rule` resources are used for external/aws-managed firewall domain lists to prevent orphan empty domain lists| +|Per-item tag merging|Each resource merges `module.this.tags` with per-item `tags`, ensuring standard tags can never be stripped by a caller| + ## Usage ### Private hosted zone with records and cross-account authorization @@ -171,6 +180,100 @@ module "r53" { +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.51.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [hosted\_zones](#module\_hosted\_zones) | terraform-aws-modules/route53/aws | 6.5.0 | +| [resolver\_endpoint\_label](#module\_resolver\_endpoint\_label) | ../tags | n/a | +| [resolver\_endpoints](#module\_resolver\_endpoints) | terraform-aws-modules/route53/aws//modules/resolver-endpoint | 6.5.0 | +| [resolver\_firewall\_association\_label](#module\_resolver\_firewall\_association\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_group\_label](#module\_resolver\_firewall\_rule\_group\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_groups](#module\_resolver\_firewall\_rule\_groups) | terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group | 6.5.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_route53_resolver_firewall_rule.external](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule) | resource | +| [aws_route53_resolver_firewall_rule_group_association.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule_group_association) | resource | + +## 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 | +| [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 | +| [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 | +| [hosted\_zones](#input\_hosted\_zones) | Map of hosted zones to create or adopt.

Each key is a logical identifier used only in Terraform state and outputs.
Each value forwards to the upstream `terraform-aws-modules/route53/aws`
root module.

`name` is the DNS zone name (for example `example.internal` or
`example.nhs.uk`) and is required. |
map(object({
name = string
create = optional(bool, true)
create_zone = optional(bool, true)
private_zone = optional(bool, false)
vpc_id = optional(string)
comment = optional(string)
delegation_set_id = optional(string)
force_destroy = optional(bool)
enable_accelerated_recovery = optional(bool)
ignore_vpc = optional(bool, false)
vpc = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
vpc_association_authorizations = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
enable_dnssec = optional(bool, false)
create_dnssec_kms_key = optional(bool, true)
dnssec_kms_key_arn = optional(string)
dnssec_kms_key_description = optional(string)
dnssec_kms_key_aliases = optional(list(string), [])
dnssec_kms_key_tags = optional(map(string), {})
dnssec_key_signing_key_name = optional(string)
records = optional(any, {})
tags = optional(map(string), {})
timeouts = optional(object({
create = optional(string)
update = optional(string)
delete = optional(string)
}))
}))
| `{}` | 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 | +| [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 | +| [resolver\_endpoints](#input\_resolver\_endpoints) | Map of Route53 Resolver endpoints to create.

Each key is a logical identifier used in Terraform state and outputs.
Names default to a context-derived value when `name` is omitted. |
map(object({
create = optional(bool, true)
region = optional(string)
name = optional(string)
direction = optional(string, "INBOUND")
type = optional(string)
protocols = optional(list(string), ["Do53"])
ip_address = optional(list(object({
ip = optional(string)
ipv6 = optional(string)
subnet_id = string
})), [])
security_group_ids = optional(list(string), [])
create_security_group = optional(bool, true)
security_group_name = optional(string)
security_group_use_name_prefix = optional(bool, false)
security_group_description = optional(string)
vpc_id = optional(string)
security_group_ingress_rules = optional(map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
})), {})
security_group_egress_rules = optional(map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
})), {})
security_group_tags = optional(map(string), {})
rules = optional(map(object({
domain_name = string
name = optional(string)
rule_type = string
tags = optional(map(string), {})
target_ip = optional(list(object({
ip = string
ipv6 = optional(string)
port = optional(number)
protocol = optional(string)
})))
vpc_id = optional(string)
})), {})
tags = optional(map(string), {})
}))
| `{}` | no | +| [resolver\_firewall\_rule\_groups](#input\_resolver\_firewall\_rule\_groups) | Map of Route53 Resolver DNS Firewall rule groups to create.

Each rule can either create a dedicated firewall domain list via `domains`
or reference an existing one via `firewall_domain_list_id`. |
map(object({
create = optional(bool, true)
region = optional(string)
name = optional(string)
ram_resource_associations = optional(map(object({
resource_share_arn = string
})), {})
vpc_ids = optional(map(string), {})
priority = optional(number, 100)
rules = optional(map(object({
name = optional(string)
domains = optional(list(string))
action = string
block_override_dns_type = optional(string)
block_override_domain = optional(string)
block_override_ttl = optional(number)
block_response = optional(string)
firewall_domain_list_id = optional(string)
firewall_domain_redirection_action = optional(string)
priority = number
q_type = optional(string)
tags = optional(map(string), {})
})), {})
tags = optional(map(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 | +| [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 | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [hosted\_zone\_arns](#output\_hosted\_zone\_arns) | Map of hosted zone key -> Route53 hosted zone ARN. | +| [hosted\_zone\_ids](#output\_hosted\_zone\_ids) | Map of hosted zone key -> Route53 hosted zone ID. | +| [hosted\_zone\_name\_servers](#output\_hosted\_zone\_name\_servers) | Map of hosted zone key -> authoritative name servers. | +| [hosted\_zone\_names](#output\_hosted\_zone\_names) | Map of hosted zone key -> Route53 hosted zone name. | +| [hosted\_zone\_records](#output\_hosted\_zone\_records) | Map of hosted zone key -> records created in the zone. | +| [resolver\_endpoint\_arns](#output\_resolver\_endpoint\_arns) | Map of resolver endpoint key -> endpoint ARN. | +| [resolver\_endpoint\_created\_security\_group\_ids](#output\_resolver\_endpoint\_created\_security\_group\_ids) | Map of resolver endpoint key -> created security group ID. | +| [resolver\_endpoint\_host\_vpc\_ids](#output\_resolver\_endpoint\_host\_vpc\_ids) | Map of resolver endpoint key -> host VPC ID. | +| [resolver\_endpoint\_ids](#output\_resolver\_endpoint\_ids) | Map of resolver endpoint key -> endpoint ID. | +| [resolver\_endpoint\_ip\_addresses](#output\_resolver\_endpoint\_ip\_addresses) | Map of resolver endpoint key -> endpoint IP addresses. | +| [resolver\_endpoint\_rules](#output\_resolver\_endpoint\_rules) | Map of resolver endpoint key -> resolver rules created by that endpoint module. | +| [resolver\_endpoint\_security\_group\_arns](#output\_resolver\_endpoint\_security\_group\_arns) | Map of resolver endpoint key -> created security group ARN. | +| [resolver\_endpoint\_security\_group\_ids](#output\_resolver\_endpoint\_security\_group\_ids) | Map of resolver endpoint key -> attached security group IDs. | +| [resolver\_firewall\_rule\_group\_arns](#output\_resolver\_firewall\_rule\_group\_arns) | Map of firewall rule group key -> rule group ARN. | +| [resolver\_firewall\_rule\_group\_domain\_lists](#output\_resolver\_firewall\_rule\_group\_domain\_lists) | Map of firewall rule group key -> domain lists created in that group. | +| [resolver\_firewall\_rule\_group\_ids](#output\_resolver\_firewall\_rule\_group\_ids) | Map of firewall rule group key -> rule group ID. | +| [resolver\_firewall\_rule\_group\_ram\_resource\_associations](#output\_resolver\_firewall\_rule\_group\_ram\_resource\_associations) | Map of firewall rule group key -> RAM resource associations created for that group. | +| [resolver\_firewall\_rule\_group\_rules](#output\_resolver\_firewall\_rule\_group\_rules) | Map of firewall rule group key -> firewall rules created in that group. | +| [resolver\_firewall\_rule\_group\_share\_statuses](#output\_resolver\_firewall\_rule\_group\_share\_statuses) | Map of firewall rule group key -> RAM share status. | diff --git a/infrastructure/modules/r53/locals.tf b/infrastructure/modules/r53/locals.tf new file mode 100644 index 00000000..c6da6585 --- /dev/null +++ b/infrastructure/modules/r53/locals.tf @@ -0,0 +1,42 @@ +################################################################ +# Locals — Firewall Rule Group helpers +# +# These locals support the upstream defect workaround documented +# in main.tf. See the REVERT INSTRUCTIONS comment there for +# removal guidance once the upstream fix lands. +################################################################ + +locals { + # Rules that create their own domain list (passed to community module) + firewall_domain_rules = { + for gk, g in var.resolver_firewall_rule_groups : gk => { + for rk, r in g.rules : rk => r + if r.firewall_domain_list_id == null + } + } + + # Rules that reference an existing domain list (standalone resources) + firewall_external_rules = merge([ + for gk, g in var.resolver_firewall_rule_groups : { + for rk, r in g.rules : "${gk}/${rk}" => merge(r, { group_key = gk }) + if r.firewall_domain_list_id != null + } + ]...) +} + +################################################################ +# Locals — Firewall Rule Group VPC Associations +################################################################ + +locals { + firewall_vpc_associations = merge([ + for group_key, group in var.resolver_firewall_rule_groups : { + for vpc_key, vpc_id in group.vpc_ids : + "${group_key}-${vpc_key}" => { + group_key = group_key + vpc_id = vpc_id + priority = group.priority + } + } + ]...) +} diff --git a/infrastructure/modules/r53/main.tf b/infrastructure/modules/r53/main.tf index db1a8f6c..dea2dc5c 100644 --- a/infrastructure/modules/r53/main.tf +++ b/infrastructure/modules/r53/main.tf @@ -137,7 +137,7 @@ module "resolver_endpoints" { # only for rules that do NOT supply `firewall_domain_list_id`: # # 1. Remove the `firewall_domain_rules` and -# `firewall_external_rules` locals below. +# `firewall_external_rules` locals in `locals.tf`. # 2. Remove the `aws_route53_resolver_firewall_rule.external` # resource block. # 3. Change `module.resolver_firewall_rule_groups` to pass @@ -147,24 +147,6 @@ module "resolver_endpoints" { # adopted by the community module with no diff. ################################################################ -locals { - # Rules that create their own domain list (passed to community module) - firewall_domain_rules = { - for gk, g in var.resolver_firewall_rule_groups : gk => { - for rk, r in g.rules : rk => r - if r.firewall_domain_list_id == null - } - } - - # Rules that reference an existing domain list (standalone resources) - firewall_external_rules = merge([ - for gk, g in var.resolver_firewall_rule_groups : { - for rk, r in g.rules : "${gk}/${rk}" => merge(r, { group_key = gk }) - if r.firewall_domain_list_id != null - } - ]...) -} - module "resolver_firewall_rule_groups" { source = "terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group" version = "6.5.0" @@ -206,19 +188,6 @@ resource "aws_route53_resolver_firewall_rule" "external" { # create them here. ################################################################ -locals { - firewall_vpc_associations = merge([ - for group_key, group in var.resolver_firewall_rule_groups : { - for vpc_key, vpc_id in group.vpc_ids : - "${group_key}-${vpc_key}" => { - group_key = group_key - vpc_id = vpc_id - priority = group.priority - } - } - ]...) -} - resource "aws_route53_resolver_firewall_rule_group_association" "this" { for_each = module.this.enabled ? local.firewall_vpc_associations : {} From 40c5214f731e5d44429b34df864aec0426773e31 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:51:07 +0100 Subject: [PATCH 6/7] chore: trigger build From b031972b88d277e3d33d001f6cc2f8f76926db23 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:55:05 +0100 Subject: [PATCH 7/7] feat(gitleaks): enhance gitleaks configuration for IPv4 and IPv6 rules --- .gitleaksignore | 4 ++++ scripts/config/gitleaks.toml | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) 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/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]