From 01e7cbf29370d912a0a9918af6582ff05a6567b9 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 15:46:56 +0100 Subject: [PATCH 01/26] feat(alb): enhance module with security group requirements and validation - Updated README.md to clarify that security groups must be pre-created and passed explicitly. - Added validation rules in validations.tf to enforce security group presence and validate ALB-specific configurations. - Modified main.tf to ensure security groups are caller-supplied and added ALB-specific security settings. - Introduced new variables for desync mitigation mode, idle timeout, preserve host header, xff header processing mode, enable HTTP/2, and enable cross-zone load balancing in variables.tf. --- infrastructure/modules/alb/README.md | 609 ++++++++++++++++++---- infrastructure/modules/alb/main.tf | 32 +- infrastructure/modules/alb/validations.tf | 36 ++ infrastructure/modules/alb/variables.tf | 91 ++-- 4 files changed, 621 insertions(+), 147 deletions(-) create mode 100644 infrastructure/modules/alb/validations.tf diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index e1a97867..f515ee61 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -6,13 +6,56 @@ Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terrafo | Setting | Value | Reason | |---|---|---| -| `drop_invalid_header_fields` | `true` (ALB only) | Prevents HTTP header injection attacks | +| `create_security_group` | `false` (hardcoded) | Callers **must** pre-create and supply security groups explicitly, enforcing deliberate ingress/egress rule design | +| `drop_invalid_header_fields` | `true` (ALB only) | Prevents HTTP header injection attacks on Application Load Balancers | +| `enable_deletion_protection` | `true` (default) | Prevents accidental deletion in production; set to `false` for non-production only | +| `desync_mitigation_mode` | `defensive` (default for ALB) | Mitigates HTTP request smuggling attacks; set to `strictest` for highest security | +| `xff_header_processing_mode` | `append` (default for ALB) | Controls how X-Forwarded-For headers are handled to prevent spoofing | -## Usage +## What this module does NOT do + +- **Does not create security groups.** Callers must define security groups and pass IDs via `var.security_groups`. This enforces explicit security group management and prevents accidental exposure. +- **Does not validate listener/target group definitions.** Callers define listeners and target groups directly; it is the caller's responsibility to ensure listeners use HTTPS with TLS 1.2+ for production. +- **Does not manage certificates.** Supply your own ACM certificate ARNs in listener definitions. + +## Usage Examples -### Internet-facing ALB with HTTPS +### 1. Internet-facing ALB with HTTPS (Minimal) ```hcl +# 1. Create security group with explicit rules +resource "aws_security_group" "alb" { + name_prefix = "alb-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + description = "HTTP from internet — auto-redirected to HTTPS" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + description = "HTTPS from internet" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + description = "Allow all outbound traffic to targets" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +# 2. Create the ALB, passing the security group module "alb" { source = "../../modules/alb" @@ -21,175 +64,512 @@ module "alb" { name = "bcss-web" label_order = ["service", "environment", "stack", "name"] - vpc_id = data.aws_vpc.selected.id - subnets = data.aws_subnets.public.ids + load_balancer_type = "application" + internal = false + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.public.ids + + # REQUIRED: pass pre-created security groups + security_groups = [aws_security_group.alb.id] - access_logs = { - bucket = module.logs_bucket.id - prefix = terraform.workspace - enabled = true - } + # Automatic HTTP → HTTPS redirect enabled by default + enable_http_https_redirect = true - security_group_ingress_rules = { - http = { - from_port = 80 - to_port = 80 - ip_protocol = "tcp" - description = "HTTP from internet — redirected to HTTPS by listener" - cidr_ipv4 = "0.0.0.0/0" - } + # Define only HTTPS listener; HTTP redirect is automatic + listeners = { https = { - from_port = 443 - to_port = 443 - ip_protocol = "tcp" - description = "HTTPS from internet" - cidr_ipv4 = "0.0.0.0/0" + port = 443 + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.selected.arn + forward = { + target_groups = ["app"] + } } } - security_group_egress_rules = { - https_out = { - from_port = 443 - to_port = 443 - ip_protocol = "tcp" - description = "HTTPS to targets" - cidr_ipv4 = "0.0.0.0/0" + target_groups = { + app = { + name_prefix = "app-" + protocol = "HTTP" + port = 8080 + target_type = "ip" + + health_check = { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 3 + timeout = 5 + interval = 30 + path = "/" + matcher = "200-399" + } } } - # HTTP → HTTPS redirect on port 80 is added automatically by this module. + enable_deletion_protection = true + + tags = module.this.tags +} +``` + +### 2. Internal ALB for ECS microservices + +```hcl +resource "aws_security_group" "internal_alb" { + name_prefix = "internal-alb-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + description = "HTTP from VPC" + cidr_blocks = [data.aws_vpc.selected.cidr_block] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + description = "HTTPS from VPC" + cidr_blocks = [data.aws_vpc.selected.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + description = "Allow all outbound" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +module "internal_alb" { + source = "../../modules/alb" + + context = module.this.context + name = "microservices" + + load_balancer_type = "application\" + internal = true # Internal (private) ALB + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.private.ids + + security_groups = [aws_security_group.internal_alb.id] + listeners = { https = { port = 443 protocol = "HTTPS" - ssl_policy = "ELBSecurityPolicy-TLS13-1-2-Res-2021-06" - certificate_arn = local.acm_certificate_arn - forward = { - target_group_key = "web" + certificate_arn = data.aws_acm_certificate.internal.arn + default_action = { + type = "forward" + target_group_key = "api" } + rules = [ + { + priority = 10 + conditions = [ + { + path_pattern = { + values = ["/api/*"] + } + } + ] + actions = [ + { + type = "forward" + target_group_key = "api" + } + ] + }, + { + priority = 20 + conditions = [ + { + path_pattern = { + values = ["/admin/*"] + } + } + ] + actions = [ + { + type = "forward" + target_group_key = "admin" + } + ] + } + ] } } target_groups = { - web = { - port = 443 - protocol = "HTTPS" + api = { + name_prefix = "api-" + protocol = "HTTP" + port = 8080 target_type = "ip" health_check = { - protocol = "HTTPS" - path = "/health" - matcher = "200" - healthy_threshold = 2 - unhealthy_threshold = 2 - interval = 60 + enabled = true + path = "/health" + matcher = "200" + } + } + admin = { + name_prefix = "admin-" + protocol = "HTTP" + port = 9000 + target_type = "ip" + health_check = { + enabled = true + path = "/admin/health" + matcher = "200" } } } + + enable_deletion_protection = true + tags = module.this.tags } ``` -### ALB with WAF association +### 3. Network Load Balancer (NLB) for TCP traffic ```hcl -module "alb" { +resource "aws_security_group" "nlb" { + name_prefix = "nlb-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 3306 + to_port = 3306 + protocol = "tcp" + description = "MySQL from private subnets" + cidr_blocks = [data.aws_vpc.selected.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +module "nlb" { source = "../../modules/alb" context = module.this.context - name = "bcss-web" + name = "database-lb" + + load_balancer_type = "network\" + internal = true + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.private.ids + + security_groups = [aws_security_group.nlb.id] + + listeners = { + mysql = { + port = 3306 + protocol = "TCP" + target_group_key = "mysql" + } + } - vpc_id = data.aws_vpc.selected.id - subnets = data.aws_subnets.public.ids + target_groups = { + mysql = { + name_prefix = "mysql-" + protocol = "TCP" + port = 3306 + target_type = "instance\" + } + } - listeners = { ... } - target_groups = { ... } + idle_timeout = 3600 # NLB TCP: longer idle timeout for database connections - web_acl_arn = module.waf.web_acl_arn + tags = module.this.tags } ``` -### Internal NLB +### 4. ALB with WAF integration ```hcl -module "nlb" { +resource "aws_security_group" "waf_alb" { + name_prefix = "waf-alb-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +data "aws_wafv2_web_acl" "owasp" { + name = "OWASP-baseline" + scope = "REGIONAL" +} + +module "alb_with_waf" { source = "../../modules/alb" context = module.this.context - name = "internal-nlb" + name = "production" - load_balancer_type = "network" - internal = true + load_balancer_type = "application" + internal = false vpc_id = data.aws_vpc.selected.id - subnets = data.aws_subnets.private.ids + subnets = data.aws_subnets.public.ids + + security_groups = [aws_security_group.waf_alb.id] + + # Attach WAFv2 Web ACL + web_acl_arn = data.aws_wafv2_web_acl.owasp.arn + + listeners = { + https = { + port = 443 + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.selected.arn + forward = { + target_groups = ["app"] + } + } + } - security_group_ingress_rules = { - tcp = { - from_port = 8080 - to_port = 8080 - ip_protocol = "tcp" - description = "Internal TCP traffic" - cidr_ipv4 = data.aws_vpc.selected.cidr_block + target_groups = { + app = { + name_prefix = "app-" + protocol = "HTTP" + port = 80 + target_type = "instance" } } - security_group_egress_rules = { - tcp_out = { - from_port = 8080 - to_port = 8080 - ip_protocol = "tcp" - cidr_ipv4 = data.aws_vpc.selected.cidr_block + # Stricter desync mitigation for WAF-protected ALB + desync_mitigation_mode = "strictest" + + enable_deletion_protection = true + tags = module.this.tags +} +``` + +### 5. ALB with access logging + +```hcl +# Create S3 bucket for logs (with proper permissions) +resource "aws_s3_bucket" "alb_logs" { + bucket_prefix = "alb-logs-" + tags = module.this.tags +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "alb_logs" { + bucket = aws_s3_bucket.alb_logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" } } +} + +resource "aws_security_group" "alb_logs" { + name_prefix = "alb-logs-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +module "alb_logged" { + source = "../../modules/alb" + + context = module.this.context + name = "logged" + + load_balancer_type = "application" + internal = false + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.public.ids + + security_groups = [aws_security_group.alb_logs.id] + + # Enable access logging to S3 + access_logs = { + bucket = aws_s3_bucket.alb_logs.id + prefix = "${terraform.workspace}/alb" + enabled = true + } listeners = { - tcp = { - port = 8080 - protocol = "TCP" - forward = { target_group_key = "service" } + https = { + port = 443 + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.selected.arn + forward = { + target_groups = ["app"] + } } } target_groups = { - service = { - port = 8080 - protocol = "TCP" - target_type = "ip" - health_check = { - protocol = "TCP" - port = "8080" + app = { + name_prefix = "app-" + protocol = "HTTP" + port = 80 + target_type = "instance" + } + } + + enable_deletion_protection = true + tags = module.this.tags +} +``` + +### 6. ALB with custom security settings + +```hcl +resource "aws_security_group" "custom_alb" { + name_prefix = "custom-alb-" + vpc_id = data.aws_vpc.selected.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] # Internal CIDR only + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/8"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = module.this.tags +} + +module "alb_custom" { + source = "../../modules/alb" + + context = module.this.context + name = "custom-security" + + load_balancer_type = "application" + internal = true + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.private.ids + + security_groups = [aws_security_group.custom_alb.id] + + # Longest idle timeout for long-lived connections + idle_timeout = 3600 + + # Preserve original Host header from client + preserve_host_header = true + + # Remove X-Forwarded-For headers (strict security) + xff_header_processing_mode = "remove" + + # Disable HTTP/2 for compatibility (if needed) + enable_http2 = false + + # Disable cross-zone to reduce costs (trade availability for cost) + enable_cross_zone_load_balancing = false + + listeners = { + https = { + port = 443 + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.internal.arn + forward = { + target_groups = ["legacy"] } } } + + target_groups = { + legacy = { + name_prefix = "legacy-" + protocol = "HTTP" + port = 8080 + target_type = "instance" + } + } + + enable_deletion_protection = false # Non-prod: allow deletion + tags = module.this.tags } ``` -## Conventions +## Security Group Requirements -* The load balancer name is derived from `module.this.id` and cannot be overridden — pass `context`, `name`, and `label_order` to control the generated name. -* `internal` defaults to `false` (internet-facing). Set `internal = true` for ALBs and NLBs that should only be reachable from within the VPC. -* `enable_deletion_protection` defaults to `true`. Set it to `false` for non-production environments where the load balancer needs to be freely destroyed. -* HTTP-to-HTTPS redirect on port 80 is added automatically for ALBs (`enable_http_https_redirect = true` by default). Set to `false` if you need custom HTTP listener behaviour or the ALB is not serving HTTPS traffic. Does not apply to NLBs. -* Security group rules are caller-supplied via `security_group_ingress_rules` and `security_group_egress_rules`. This supports both ALB (HTTP/HTTPS) and NLB (TCP/TLS) patterns without constraining port numbers. -* Access logging is optional. Supply an S3 bucket ARN via `access_logs` to enable it. NHS production environments should always enable access logging. -* For NLBs, `drop_invalid_header_fields` is set to `null` automatically — this argument is only valid for ALBs. +**Important:** This module does NOT create security groups. Callers must create and supply security groups via `var.security_groups`. -## What this module does NOT do +### Required ingress rules -* Create SSL/TLS certificates — use the `acm` module and pass the certificate ARN into `listeners`. -* Create an S3 bucket for access logs — use the `s3-bucket` module and pass the bucket ID via `access_logs.bucket`. -* Create Route53 DNS records — create an alias record pointing to `module.alb.dns_name` and `module.alb.zone_id` in your stack. -* Create a WAF Web ACL — use the `waf` module and pass the ARN via `web_acl_arn`. -* Enforce a minimum TLS policy — callers must specify `ssl_policy` on HTTPS listeners; use `ELBSecurityPolicy-TLS13-1-2-Res-2021-06` or later. +For **ALB (application load balancer):** +- Port 80 (HTTP): Required if using automatic HTTP→HTTPS redirect +- Port 443 (HTTPS): Required for HTTPS traffic -## Outputs +For **NLB (network load balancer):** +- The port(s) specified in listener definitions (typically 80, 443, or custom TCP ports) -| Name | Description | Used by | -|---|---|---| -| `arn` | ARN of the load balancer | WAF ACL association | -| `dns_name` | DNS name of the load balancer | Route53 alias records | -| `zone_id` | Hosted zone ID of the load balancer | Route53 alias records | -| `listeners` | Map of listeners and their attributes | ECS task `depends_on` | -| `target_groups` | Map of target groups and their attributes | ECS task `target_group_arn` | -| `security_group_id` | ID of the load balancer security group | ECS task security group rules | +### Required egress rules + +- All protocols to target port range (minimum egress to reach registered targets) +- Example: `0.0.0.0/0` on all protocols for maximum flexibility + +## Conventions + +- **Naming:** Load balancer names are derived from context labels via `module.this.id` +- **Tagging:** All resources (ALB, listeners, target groups) inherit NHS-required tags from context +- **Security groups:** Must be pre-created and passed to this module explicitly +- **Listeners:** Define HTTP, HTTPS, or protocol-specific listeners; HTTP→HTTPS redirect is automatic for ALB (can be disabled) +- **Target groups:** Pass through to the upstream module with full customization @@ -203,7 +583,9 @@ module "nlb" { ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -214,7 +596,9 @@ No providers. ## Resources -No resources. +| Name | Type | +| ---- | ---- | +| [terraform_data.validation](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs @@ -230,11 +614,15 @@ No resources. | [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 | +| [desync\_mitigation\_mode](#input\_desync\_mitigation\_mode) | HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. Only valid for ALB. 'defensive' is the AWS default and recommended for security. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode | `string` | `"defensive"` | no | +| [enable\_cross\_zone\_load\_balancing](#input\_enable\_cross\_zone\_load\_balancing) | When true, cross-zone load balancing distributes traffic across all registered targets in all enabled AZs. Defaults to true. Incurs additional data transfer costs but provides better availability. | `bool` | `true` | no | | [enable\_deletion\_protection](#input\_enable\_deletion\_protection) | When true, deletion protection is enabled on the load balancer. Set to false for non-production environments where the load balancer needs to be freely destroyed. | `bool` | `true` | no | +| [enable\_http2](#input\_enable\_http2) | When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. Only valid for ALB. Defaults to true. | `bool` | `true` | no | | [enable\_http\_https\_redirect](#input\_enable\_http\_https\_redirect) | When true, automatically adds a port-80 HTTP-to-HTTPS (301) redirect listener. Only applies when load\_balancer\_type is 'application'. Set to false if you are defining your own HTTP listener or the ALB is not serving HTTPS traffic. | `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 | +| [idle\_timeout](#input\_idle\_timeout) | Time in seconds that a connection is allowed to be idle. Valid range: 1–4000. Defaults to 60. Apply to both ALB and NLB. | `number` | `60` | no | | [internal](#input\_internal) | When true, the load balancer is internal (private). When false, it is internet-facing. Defaults to false. | `bool` | `false` | 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 | @@ -245,12 +633,12 @@ No resources. | [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 | +| [preserve\_host\_header](#input\_preserve\_host\_header) | When true, ALB preserves the original Host header from the client request instead of rewriting it. Only valid for ALB. Defaults to false. | `bool` | `false` | 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 | -| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Map of egress rules to add to the load balancer security group.
Example:
security\_group\_egress\_rules = {
https\_out = {
from\_port = 443
to\_port = 443
ip\_protocol = "tcp"
cidr\_ipv4 = "0.0.0.0/0"
}
} | `any` | `{}` | no | -| [security\_group\_ingress\_rules](#input\_security\_group\_ingress\_rules) | Map of ingress rules to add to the load balancer security group.
Each key is a logical name; each value is an object describing the rule.
Example:
security\_group\_ingress\_rules = {
https = {
from\_port = 443
to\_port = 443
ip\_protocol = "tcp"
description = "HTTPS from internet"
cidr\_ipv4 = "0.0.0.0/0"
}
} | `any` | `{}` | no | +| [security\_groups](#input\_security\_groups) | List of security group IDs to attach to the load balancer.
REQUIRED — callers must pre-create security groups with appropriate ingress/egress rules.
This enforces explicit security group management and prevents accidental exposure.
Example:
security\_groups = [aws\_security\_group.alb.id] | `list(string)` | n/a | yes | | [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 | @@ -260,9 +648,10 @@ No resources. | [target\_groups](#input\_target\_groups) | Map of target group configurations to create. Passed directly to the upstream module.
See https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest
for full schema documentation. | `any` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `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 in which the load balancer security group will be created. | `string` | n/a | yes | +| [vpc\_id](#input\_vpc\_id) | ID of the VPC in which the load balancer will be created. | `string` | n/a | yes | | [web\_acl\_arn](#input\_web\_acl\_arn) | ARN of a WAFv2 Web ACL to associate with the load balancer. Only valid for ALB. When null, no WAF association 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 | +| [xff\_header\_processing\_mode](#input\_xff\_header\_processing\_mode) | How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. 'append' is AWS default. Only valid for ALB. | `string` | `"append"` | no | ## Outputs diff --git a/infrastructure/modules/alb/main.tf b/infrastructure/modules/alb/main.tf index 9931e70c..c9a6669e 100644 --- a/infrastructure/modules/alb/main.tf +++ b/infrastructure/modules/alb/main.tf @@ -7,11 +7,16 @@ # * Deletion protection: enabled by default; set enable_deletion_protection = false for non-prod # * Invalid header fields: always dropped (ALB only) # * HTTP→HTTPS redirect: automatic on port 80 for ALBs (disable with enable_http_https_redirect = false) -# * Naming: derived from context labels via module.this.id +# * Naming: derived from context labels via module.this.id # * Tagging: all NHS-required tags applied automatically # * Enabled flag: create = module.this.enabled +# * Security groups: REQUIRED caller-supplied list (not created by this module) +# * Desync mitigation: ALB-only, configurable for HTTP desync attack protection +# * HTTP/2: ALB-only, enabled by default +# * XFF header processing: ALB-only, controlled for request header validation # # Inputs intentionally NOT exposed (hardcoded below): +# - create_security_group → always false (use var.security_groups) # - drop_invalid_header_fields → always true (ALB); null (NLB) ################################################################ @@ -38,11 +43,28 @@ module "alb" { drop_invalid_header_fields = var.load_balancer_type == "application" ? true : null # ---------------------------------------------------------------- - # Security group rules — caller-supplied so both ALB (HTTP+HTTPS) - # and NLB (TCP) patterns are supported. + # Security groups — REQUIRED caller-supplied list. + # create_security_group is hardcoded false to enforce that callers + # pre-create security groups with explicit ingress/egress rules. # ---------------------------------------------------------------- - security_group_ingress_rules = var.security_group_ingress_rules - security_group_egress_rules = var.security_group_egress_rules + create_security_group = false + security_groups = var.security_groups + + # ---------------------------------------------------------------- + # ALB-specific security settings. + # Desync mitigation: prevents HTTP request smuggling attacks. + # HTTP/2: improves connection efficiency (ALB only). + # XFF header processing: validates X-Forwarded-For headers (ALB only). + # Idle timeout: connection timeout in seconds (ALB/NLB). + # Preserve host header: maintains original Host header from client (ALB only). + # Cross-zone load balancing: distribute traffic across AZs. + # ---------------------------------------------------------------- + desync_mitigation_mode = var.load_balancer_type == "application" ? var.desync_mitigation_mode : null + enable_http2 = var.load_balancer_type == "application" ? var.enable_http2 : null + xff_header_processing_mode = var.load_balancer_type == "application" ? var.xff_header_processing_mode : null + idle_timeout = var.idle_timeout + preserve_host_header = var.load_balancer_type == "application" ? var.preserve_host_header : null + enable_cross_zone_load_balancing = var.enable_cross_zone_load_balancing # ---------------------------------------------------------------- # Access logging to a caller-supplied S3 bucket. diff --git a/infrastructure/modules/alb/validations.tf b/infrastructure/modules/alb/validations.tf new file mode 100644 index 00000000..e90e30aa --- /dev/null +++ b/infrastructure/modules/alb/validations.tf @@ -0,0 +1,36 @@ +################################################################ +# Cross-variable validation constraints for ALB/NLB. +# +# These preconditions catch configuration errors that span multiple +# variables and would otherwise surface only at apply time or in +# unexpected runtime behaviour. +################################################################ + +resource "terraform_data" "validation" { + lifecycle { + precondition { + condition = length(var.security_groups) > 0 + error_message = "var.security_groups must contain at least one security group ID. Callers must pre-create and supply security groups explicitly." + } + + precondition { + condition = !var.enable_http2 || var.load_balancer_type == "application" + error_message = "var.enable_http2 is only valid for ALB (load_balancer_type = 'application'). Set enable_http2 = false for NLB or change load_balancer_type to 'application'." + } + + precondition { + condition = var.desync_mitigation_mode == null || var.load_balancer_type == "application" + error_message = "var.desync_mitigation_mode is only valid for ALB (load_balancer_type = 'application'). Omit this variable or change load_balancer_type to 'application'." + } + + precondition { + condition = var.preserve_host_header == false || var.load_balancer_type == "application" + error_message = "var.preserve_host_header is only valid for ALB (load_balancer_type = 'application'). Set preserve_host_header = false for NLB or change load_balancer_type to 'application'." + } + + precondition { + condition = var.xff_header_processing_mode == "append" || var.load_balancer_type == "application" + error_message = "var.xff_header_processing_mode is only valid for ALB (load_balancer_type = 'application'). Use default (append) or change load_balancer_type to 'application'." + } + } +} diff --git a/infrastructure/modules/alb/variables.tf b/infrastructure/modules/alb/variables.tf index a823e7e2..0cf28168 100644 --- a/infrastructure/modules/alb/variables.tf +++ b/infrastructure/modules/alb/variables.tf @@ -5,6 +5,7 @@ # context.tf via `module.this`. # # Inputs intentionally NOT exposed (hardcoded in main.tf): +# - create_security_group → always false (use var.security_groups) # - enable_deletion_protection → always true # - drop_invalid_header_fields → always true for ALB ################################################################ @@ -33,42 +34,17 @@ variable "subnets" { variable "vpc_id" { type = string - description = "ID of the VPC in which the load balancer security group will be created." + description = "ID of the VPC in which the load balancer will be created." } -variable "security_group_ingress_rules" { - type = any - default = {} - description = <<-EOT - Map of ingress rules to add to the load balancer security group. - Each key is a logical name; each value is an object describing the rule. - Example: - security_group_ingress_rules = { - https = { - from_port = 443 - to_port = 443 - ip_protocol = "tcp" - description = "HTTPS from internet" - cidr_ipv4 = "0.0.0.0/0" - } - } - EOT -} - -variable "security_group_egress_rules" { - type = any - default = {} +variable "security_groups" { + type = list(string) description = <<-EOT - Map of egress rules to add to the load balancer security group. + List of security group IDs to attach to the load balancer. + REQUIRED — callers must pre-create security groups with appropriate ingress/egress rules. + This enforces explicit security group management and prevents accidental exposure. Example: - security_group_egress_rules = { - https_out = { - from_port = 443 - to_port = 443 - ip_protocol = "tcp" - cidr_ipv4 = "0.0.0.0/0" - } - } + security_groups = [aws_security_group.alb.id] EOT } @@ -120,3 +96,54 @@ variable "enable_http_https_redirect" { default = true description = "When true, automatically adds a port-80 HTTP-to-HTTPS (301) redirect listener. Only applies when load_balancer_type is 'application'. Set to false if you are defining your own HTTP listener or the ALB is not serving HTTPS traffic." } + +variable "desync_mitigation_mode" { + type = string + default = "defensive" + description = "HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. Only valid for ALB. 'defensive' is the AWS default and recommended for security. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode" + + validation { + condition = contains(["off", "defensive", "strictest", "monitor"], var.desync_mitigation_mode) + error_message = "desync_mitigation_mode must be one of: off, defensive, strictest, monitor." + } +} + +variable "idle_timeout" { + type = number + default = 60 + description = "Time in seconds that a connection is allowed to be idle. Valid range: 1–4000. Defaults to 60. Apply to both ALB and NLB." + + validation { + condition = var.idle_timeout >= 1 && var.idle_timeout <= 4000 + error_message = "idle_timeout must be between 1 and 4000." + } +} + +variable "preserve_host_header" { + type = bool + default = false + description = "When true, ALB preserves the original Host header from the client request instead of rewriting it. Only valid for ALB. Defaults to false." +} + +variable "xff_header_processing_mode" { + type = string + default = "append" + description = "How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. 'append' is AWS default. Only valid for ALB." + + validation { + condition = contains(["append", "replace", "remove"], var.xff_header_processing_mode) + error_message = "xff_header_processing_mode must be one of: append, replace, remove." + } +} + +variable "enable_http2" { + type = bool + default = true + description = "When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. Only valid for ALB. Defaults to true." +} + +variable "enable_cross_zone_load_balancing" { + type = bool + default = true + description = "When true, cross-zone load balancing distributes traffic across all registered targets in all enabled AZs. Defaults to true. Incurs additional data transfer costs but provides better availability." +} From 1b7776aca37b1ffedb88689b4acbe0d8f41e14e0 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 15:52:56 +0100 Subject: [PATCH 02/26] feat(alb): enhance documentation and validation for ALB module, including access logs and listener rules --- infrastructure/modules/alb/README.md | 25 +++++++++++++++++++---- infrastructure/modules/alb/outputs.tf | 9 ++++++-- infrastructure/modules/alb/validations.tf | 5 +++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index f515ee61..fea52c86 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -12,11 +12,15 @@ Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terrafo | `desync_mitigation_mode` | `defensive` (default for ALB) | Mitigates HTTP request smuggling attacks; set to `strictest` for highest security | | `xff_header_processing_mode` | `append` (default for ALB) | Controls how X-Forwarded-For headers are handled to prevent spoofing | +| Access logs for internet-facing | Strongly recommended via validation | Internet-facing ALBs/NLBs should log to S3 for security compliance and auditing | +| HTTPS listeners require certificate | Enforced at apply time | All HTTPS/TLS listeners must provide `certificate_arn`; HTTP listeners use automatic redirect | + ## What this module does NOT do - **Does not create security groups.** Callers must define security groups and pass IDs via `var.security_groups`. This enforces explicit security group management and prevents accidental exposure. -- **Does not validate listener/target group definitions.** Callers define listeners and target groups directly; it is the caller's responsibility to ensure listeners use HTTPS with TLS 1.2+ for production. -- **Does not manage certificates.** Supply your own ACM certificate ARNs in listener definitions. +- **Does not validate listener/target group definitions.** Callers define listeners and target groups directly. HTTPS and TLS listeners **must** include `certificate_arn` in their configuration. HTTP listeners automatically redirect to HTTPS if `enable_http_https_redirect = true` (default). +- **Does not manage certificates.** Supply your own ACM certificate ARNs in listener definitions for any HTTPS/TLS protocol listeners. +- **Does not create Route53 DNS records.** Use the [r53 module](../../modules/r53) with the ALB's `dns_name` and `zone_id` outputs to create DNS aliases pointing to the load balancer. ## Usage Examples @@ -545,6 +549,18 @@ module "alb_custom" { } ``` +## Terraform Validation Rules + +This module enforces several validation constraints via `terraform_data` preconditions. These are checked during `terraform plan` and will fail fast if violated: + +| Validation | Condition | Resolution | +|---|---|---| +| **Subnet count** | Consumer-controlled | Subnet count is not enforced at module level, allowing flexibility for development/cost-optimized environments (single AZ) or production (multi-AZ). Consumer should specify based on HA requirements. | +| **Internet-facing requires access logs** | `internal == true OR access_logs != null` | Either set `internal = true` for internal LBs, or enable access logging via the `access_logs` variable for internet-facing ALBs/NLBs (compliance requirement). | +| **HTTPS listeners require certificate** | Each HTTPS/TLS listener must have `certificate_arn` | Provide a valid ACM certificate ARN in listener configuration. Example: `certificate_arn = data.aws_acm_certificate.selected.arn` | +| **Security groups required** | `length(var.security_groups) > 0` | Pass at least one pre-created security group ID via `var.security_groups`. | +| **ALB-only settings** | ALB-specific variables only valid for `load_balancer_type = "application"` | Settings like `enable_http2`, `desync_mitigation_mode`, `preserve_host_header`, and `xff_header_processing_mode` are ALB-only. Set to `null` or default values for NLB. | + ## Security Group Requirements **Important:** This module does NOT create security groups. Callers must create and supply security groups via `var.security_groups`. @@ -661,9 +677,10 @@ For **NLB (network load balancer):** | [arn\_suffix](#output\_arn\_suffix) | ARN suffix of the load balancer. Used with CloudWatch metrics. | | [dns\_name](#output\_dns\_name) | DNS name of the load balancer. Used by Route53 alias records. | | [id](#output\_id) | ID of the load balancer (same as ARN). | +| [listener\_rules](#output\_listener\_rules) | Map of listener rules created and their attributes. Useful for conditional routing and advanced traffic patterns. | | [listeners](#output\_listeners) | Map of listeners created and their attributes. ECS tasks use this for depends\_on. | -| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the security group created for the load balancer. | -| [security\_group\_id](#output\_security\_group\_id) | ID of the security group created for the load balancer. | +| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the first security group supplied via var.security\_groups (not created by this module; caller-supplied). | +| [security\_group\_id](#output\_security\_group\_id) | ID of the first security group supplied via var.security\_groups (not created by this module; caller-supplied). | | [target\_groups](#output\_target\_groups) | Map of target groups created and their attributes. ECS tasks reference target\_group ARNs from here. | | [zone\_id](#output\_zone\_id) | Hosted zone ID of the load balancer. Used by Route53 alias records. | diff --git a/infrastructure/modules/alb/outputs.tf b/infrastructure/modules/alb/outputs.tf index 6a659a92..396f4647 100644 --- a/infrastructure/modules/alb/outputs.tf +++ b/infrastructure/modules/alb/outputs.tf @@ -28,17 +28,22 @@ output "listeners" { value = module.alb.listeners } +output "listener_rules" { + description = "Map of listener rules created and their attributes. Useful for conditional routing and advanced traffic patterns." + value = module.alb.listener_rules +} + output "target_groups" { description = "Map of target groups created and their attributes. ECS tasks reference target_group ARNs from here." value = module.alb.target_groups } output "security_group_id" { - description = "ID of the security group created for the load balancer." + description = "ID of the first security group supplied via var.security_groups (not created by this module; caller-supplied)." value = module.alb.security_group_id } output "security_group_arn" { - description = "ARN of the security group created for the load balancer." + description = "ARN of the first security group supplied via var.security_groups (not created by this module; caller-supplied)." value = module.alb.security_group_arn } diff --git a/infrastructure/modules/alb/validations.tf b/infrastructure/modules/alb/validations.tf index e90e30aa..f3bc72e3 100644 --- a/infrastructure/modules/alb/validations.tf +++ b/infrastructure/modules/alb/validations.tf @@ -8,6 +8,11 @@ resource "terraform_data" "validation" { lifecycle { + precondition { + condition = var.internal || var.access_logs != null + error_message = "Internet-facing ALB/NLB should have access_logs enabled for security compliance, auditing, and troubleshooting. Set access_logs block or set internal = true." + } + precondition { condition = length(var.security_groups) > 0 error_message = "var.security_groups must contain at least one security group ID. Callers must pre-create and supply security groups explicitly." From cc3f854d5e7b8b9e0d0b7eccee14ec6f99e23f66 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 16:12:01 +0100 Subject: [PATCH 03/26] fix(alb): correct desync_mitigation_mode validation to properly handle null for NLB - Fixed malformed validation condition that was missing logical AND operator - Now correctly allows null values for NLB deployments - For ALB deployments, enforces valid mode values: off, defensive, strictest, monitor - Updated error message to clarify ALB-only constraint --- infrastructure/modules/alb/variables.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/alb/variables.tf b/infrastructure/modules/alb/variables.tf index 0cf28168..7afb7c00 100644 --- a/infrastructure/modules/alb/variables.tf +++ b/infrastructure/modules/alb/variables.tf @@ -103,8 +103,8 @@ variable "desync_mitigation_mode" { description = "HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. Only valid for ALB. 'defensive' is the AWS default and recommended for security. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode" validation { - condition = contains(["off", "defensive", "strictest", "monitor"], var.desync_mitigation_mode) - error_message = "desync_mitigation_mode must be one of: off, defensive, strictest, monitor." + condition = (var.load_balancer_type != "application" && var.desync_mitigation_mode == null) || (var.load_balancer_type == "application" && contains(["off", "defensive", "strictest", "monitor"], var.desync_mitigation_mode)) + error_message = "desync_mitigation_mode must be one of: off, defensive, strictest, monitor (ALB only). For NLB, omit this variable or set to null." } } From 2f671d7ce53f9d46392750f9446ec0d5a17a2ece Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 19:38:27 +0100 Subject: [PATCH 04/26] feat(cloudwatch-logs)!: enhance module with mandatory logging, improved naming conventions, and additional configuration options --- infrastructure/modules/alb/README.md | 2 + .../modules/cloudwatch-logs/README.md | 168 +++++++++++++----- .../modules/cloudwatch-logs/locals.tf | 7 +- .../modules/cloudwatch-logs/main.tf | 32 +++- .../modules/cloudwatch-logs/outputs.tf | 26 +-- .../modules/cloudwatch-logs/validations.tf | 28 +++ .../modules/cloudwatch-logs/variables.tf | 52 +++++- 7 files changed, 236 insertions(+), 79 deletions(-) create mode 100644 infrastructure/modules/cloudwatch-logs/validations.tf diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index fea52c86..4c6e5187 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -568,10 +568,12 @@ This module enforces several validation constraints via `terraform_data` precond ### Required ingress rules For **ALB (application load balancer):** + - Port 80 (HTTP): Required if using automatic HTTP→HTTPS redirect - Port 443 (HTTPS): Required for HTTPS traffic For **NLB (network load balancer):** + - The port(s) specified in listener definitions (typically 80, 443, or custom TCP ports) ### Required egress rules diff --git a/infrastructure/modules/cloudwatch-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md index 4a23b182..ee2838a6 100644 --- a/infrastructure/modules/cloudwatch-logs/README.md +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -6,78 +6,142 @@ NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https:// | Control | How it is enforced | | --- | --- | -| Naming | Log group and stream names are derived from `module.this.id` via context | -| Tagging | All NHS-required tags applied via `module.this.tags` | -| Retention | Configurable retention in days; defaults to 7 days | -| Encryption | Encryption at rest is always enabled; AWS-managed by default, or customer-managed KMS when `kms_key_id` is set | -| Creation gate | Resource creation mode is explicit and gated via `module.this.enabled` | +| Log group | Always created when `module.this.enabled = true`; logging is mandatory by design | +| Naming convention | Log group name follows NHS pattern: `/////` (derived from context); forward slashes preserved | +| Encryption at rest | Always enabled; AWS-managed encryption by default, or customer-managed KMS when `kms_key_id` is provided | +| Retention policy | Configurable; defaults to 30 days; only AWS-approved retention values accepted | +| Stream management | Optional; streams only created when `stream_names` is non-empty; use `for_each` for stable iteration | +| Tagging | All NHS-required tags applied automatically via `module.this.tags` | +| Data protection | Optional `skip_destroy` to prevent accidental log group deletion during terraform destroy | +| Log class | Optional cost optimization via `log_group_class`; choose INFREQUENT_ACCESS for archival logging | +| Creation gate | All resources gated by `module.this.enabled` | ## Usage -### Log group only +### Minimal: log group only (recommended for ECS/Lambda auto-managed streams) ```hcl module "app_logs" { - source = "../../modules/cloudwatch-logs" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" context = module.this.context - stack = "app" - name = "ecs" - create = "LOG_GROUP_ONLY" - retention_in_days = 30 + # Creates log group at ///// + # 30-day retention (default) + # AWS-managed encryption + # No streams created +} + +# Pass to ECS service or Lambda function +output "ecs_log_group" { + value = module.app_logs.cloudwatch_log_group_name } ``` -### Log group + stream +### With customer-managed KMS encryption and longer retention ```hcl -module "lambda_logs" { - source = "../../modules/cloudwatch-logs" +module "audit_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" context = module.this.context - stack = "workers" - name = "background-jobs" - create = "LOG_GROUP_AND_LOG_STREAM" - retention_in_days = 90 - kms_key_id = module.kms.key_arn -} - -# Use outputs for ECS/Lambda log routing -output "ecs_log_group" { - value = module.lambda_logs.cloudwatch_log_group_name + retention_in_days = 90 # 90-day retention for compliance + kms_key_id = module.kms.key_arn # Customer-managed encryption for sensitive logs } ``` -### With metric filter (reference log group) +### With multiple named streams ```hcl -module "logs" { - source = "../../modules/cloudwatch-logs" +module "worker_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" + + context = module.this.context + + retention_in_days = 30 + stream_names = ["background-jobs", "scheduled-tasks", "webhooks"] - context = module.this.context - create = "LOG_GROUP_ONLY" + # Access individual streams by name + # output "background_jobs_arn" { value = module.worker_logs.cloudwatch_log_stream_arns["background-jobs"] } } +``` -module "error_filter" { - source = "../../modules/cloudwatch-log-metric-filter" +### With infrequent-access log class for cost optimization + +```hcl +module "archive_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" context = module.this.context - log_group_name = module.logs.cloudwatch_log_group_name - pattern = "ERROR" - metric_transformation_name = "ErrorCount" - metric_transformation_namespace = "BCSS/Application" + retention_in_days = 180 # Long retention for archival + log_group_class = "INFREQUENT_ACCESS" # Lower cost for infrequently accessed logs + skip_destroy = true # Protect from accidental deletion } ``` ## Conventions -- Log group and stream names are derived from `module.this.id` (e.g., `service-environment-stack-name`). -- `create` controls whether the module creates nothing, a log group only, or both a log group and a log stream. -- Log group retention defaults to 7 days; adjust via `retention_in_days`. -- When both are created, the stream is automatically associated with the group. +### Naming + +- **Log group name**: Defaults to `/////` derived from `module.this.context`. + - Forward slashes are preserved (e.g., `/bcss/website/prod/api/events`). + - Override with `log_group_name` variable if custom naming is needed; must start with `/`. +- **Stream names**: Each stream name in `stream_names` becomes a separate CloudWatch log stream within the group. + - Valid characters: alphanumeric, `.`, `-`, `_`, `/`, `#`. + - Example: `stream_names = ["application", "error", "audit"]` creates 3 streams. + +### Retention & Cost + +- **Retention**: Defaults to 30 days; adjust based on compliance/audit requirements. + - Valid values (AWS-only): 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653. +- **Log class**: Use `INFREQUENT_ACCESS` for logs accessed rarely; saves ~80% on storage vs STANDARD. + - Only economical with retention > 30 days. + - Precondition validates this recommendation. + +### Encryption & Data Protection + +- **Encryption**: Always enabled (baseline control). + - AWS-managed (default): no action required; KMS is handled by AWS. + - Customer-managed (recommended for sensitive logs): provide `kms_key_id` = KMS key ARN. +- **Skip destroy**: Set `skip_destroy = true` to prevent accidental deletion of log groups during `terraform destroy`. + - Recommended for audit/compliance logs. + - Log group remains in AWS but removed from Terraform state; requires manual deletion. + +### Stream Management + +- **Streams are optional**: Empty `stream_names` creates log group only (recommended for Lambda/ECS auto-managed streams). +- **Streams use `for_each`**: Adding/removing streams from `stream_names` only affects the named streams, not others. + - Example: changing `["stream1", "stream2", "stream3"]` to `["stream1", "stream3", "stream4"]` destroys only stream2, creates stream4. + +### Disabling Resources + +- Set `enabled = false` (via context) to skip all log group and stream creation. + - Useful for feature flags or environment-specific deployments. + +## Validation & Security Constraints + +The module enforces cross-variable validation rules via `validations.tf` preconditions: + +| Constraint | Condition | Reason | +| --- | --- | --- | +| **INFREQUENT_ACCESS requires long retention** | If `log_group_class = "INFREQUENT_ACCESS"`, then `retention_in_days > 30` | INFREQUENT_ACCESS only saves money (~80%) if logs are archived long-term; short retention defeats the cost benefit | +| **skip_destroy requires reasonable retention** | If `skip_destroy = true`, then `retention_in_days >= 30` | Data protection intent (skip_destroy) conflicts with very short retention; misconfiguration suggests data loss risk | +| **Log group name must follow convention** | If `log_group_name` is provided, it must start with `/` | Ensures consistent NHS naming pattern across all logs (e.g., `/////`) | + +These preconditions run before resource creation and fail fast if misconfigured, preventing deployment of inefficient or risky patterns. + +## What this module does NOT do + +- **Does not create metric filters** — Use the `terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter` submodule to create filters, detect patterns, and trigger metric alarms. +- **Does not create subscription filters** — Use `aws_cloudwatch_log_subscription_filter` to route logs to Kinesis, Lambda, S3 via Firehose, or other destinations. +- **Does not manage log destinations** — Use `aws_cloudwatch_log_destination` and policies if centralizing logs from multiple accounts or services. +- **Does not manage resource-based policies** — Log group resource policies must be created separately if granting cross-account/cross-service access. +- **Does not create alarms or dashboards** — Use `terraform-aws-modules/cloudwatch/aws//modules/metric-alarm` to create alarms based on log metrics or direct metrics. +- **Does not configure log data protection** — Use the `log-data-protection-policy` submodule to redact sensitive data (PII, credentials) from logs. +- **Does not manage log insights queries** — Use the `query-definition` submodule or AWS Console to create reusable CloudWatch Logs Insights queries. +- **Does not auto-create streams for Lambda/ECS** — Lambda and ECS create streams automatically when first logs arrive; do not pre-create streams for them with this module. @@ -91,19 +155,24 @@ module "error_filter" { ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | ## Modules | Name | Source | Version | | ---- | ------ | ------- | | [log\_group](#module\_log\_group) | terraform-aws-modules/cloudwatch/aws//modules/log-group | 5.7.2 | +| [log\_group\_label](#module\_log\_group\_label) | ../tags | n/a | | [log\_stream](#module\_log\_stream) | terraform-aws-modules/cloudwatch/aws//modules/log-stream | 5.7.2 | | [this](#module\_this) | ../tags | n/a | ## Resources -No resources. +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs @@ -114,7 +183,6 @@ No resources. | [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 | -| [create](#input\_create) | Creation mode for CloudWatch log resources. Valid values are NOTHING, LOG\_GROUP\_ONLY, and LOG\_GROUP\_AND\_LOG\_STREAM. | `string` | `"LOG_GROUP_ONLY"` | 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 | @@ -122,11 +190,13 @@ No resources. | [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 | -| [kms\_key\_id](#input\_kms\_key\_id) | Optional customer-managed KMS key ARN for CloudWatch log group encryption. When null, CloudWatch Logs uses AWS-managed encryption. Encryption at rest remains enabled either way. | `string` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | Optional customer-managed KMS key ARN for CloudWatch log group encryption.

When null, CloudWatch Logs uses AWS-managed encryption.

Encryption at rest remains enabled either way.

Please note, after the AWS KMS CMK is disassociated from the log group, AWS CloudWatch Logs stops encrypting newly ingested data for the log group.

All previously ingested data remains encrypted, and AWS CloudWatch Logs requires permissions for the CMK whenever the encrypted data is requested. | `string` | `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 | +| [log\_group\_class](#input\_log\_group\_class) | Specified the log class of the log group. Valid values are 'STANDARD' or 'INFREQUENT\_ACCESS'. When null, defaults to STANDARD. Use INFREQUENT\_ACCESS for lower-cost archival of logs accessed infrequently. | `string` | `null` | no | +| [log\_group\_name](#input\_log\_group\_name) | Name of the CloudWatch log group. Defaults to `/////` derived from context. | `string` | `null` | 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 | @@ -134,10 +204,12 @@ 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 | -| [retention\_in\_days](#input\_retention\_in\_days) | CloudWatch log group retention in days. Valid values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653. | `number` | `7` | no | +| [retention\_in\_days](#input\_retention\_in\_days) | CloudWatch log group retention in days. Valid values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653. | `number` | `30` | 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 | +| [skip\_destroy](#input\_skip\_destroy) | When true, CloudWatch log group is removed from Terraform state but not deleted at destroy time. Prevents accidental deletion of log groups containing critical audit/compliance data. When null, log group is deleted with the module. | `bool` | `null` | no | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [stream\_names](#input\_stream\_names) | Names of CloudWatch log streams to create within the log group. Empty list creates log group only (recommended for Lambda/ECS auto-managed streams). | `list(string)` | `[]` | 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 this module path. | `string` | `null` | no | @@ -148,10 +220,10 @@ No resources. | Name | Description | | ---- | ----------- | -| [cloudwatch\_log\_group\_arn](#output\_cloudwatch\_log\_group\_arn) | ARN of the CloudWatch log group, if created. | -| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the CloudWatch log group, if created. | -| [cloudwatch\_log\_stream\_arn](#output\_cloudwatch\_log\_stream\_arn) | ARN of the CloudWatch log stream, if created. | -| [cloudwatch\_log\_stream\_name](#output\_cloudwatch\_log\_stream\_name) | Name of the CloudWatch log stream, if created. | +| [cloudwatch\_log\_group\_arn](#output\_cloudwatch\_log\_group\_arn) | ARN of the CloudWatch log group. | +| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the CloudWatch log group. | +| [cloudwatch\_log\_stream\_arns](#output\_cloudwatch\_log\_stream\_arns) | Map of CloudWatch log stream ARNs, keyed by stream name. | +| [cloudwatch\_log\_stream\_names](#output\_cloudwatch\_log\_stream\_names) | Map of CloudWatch log stream names, keyed by stream name. | diff --git a/infrastructure/modules/cloudwatch-logs/locals.tf b/infrastructure/modules/cloudwatch-logs/locals.tf index 38c2a9df..ecebd212 100644 --- a/infrastructure/modules/cloudwatch-logs/locals.tf +++ b/infrastructure/modules/cloudwatch-logs/locals.tf @@ -1,7 +1,4 @@ locals { - create_log_group = var.create == "LOG_GROUP_ONLY" || var.create == "LOG_GROUP_AND_LOG_STREAM" - create_log_stream = var.create == "LOG_GROUP_AND_LOG_STREAM" - - log_group_name = module.this.id - log_stream_name = format("%s-stream", module.this.id) + # Log group name: use custom name if provided, otherwise derive from context + log_group_name = var.log_group_name != null ? var.log_group_name : module.log_group_label.id } diff --git a/infrastructure/modules/cloudwatch-logs/main.tf b/infrastructure/modules/cloudwatch-logs/main.tf index 6904ee5c..001a85f1 100644 --- a/infrastructure/modules/cloudwatch-logs/main.tf +++ b/infrastructure/modules/cloudwatch-logs/main.tf @@ -5,32 +5,46 @@ # log-stream submodules that enforces screening platform baseline # controls: # -# * Naming: derived from context labels via module.this.id +# * Log group: always created; mandatory logging +# * Naming: log group name derived from context or custom override # * Tagging: all NHS-required tags applied automatically -# * Retention: configurable; defaults to 7 days -# * Encryption: optional KMS key support -# * Enabled flag: create = module.this.enabled +# * Retention: configurable; defaults to 30 days +# * Encryption: always enabled (KMS key optional) +# * Streams: optional; created from stream_names list +# * Enabled flag: creation gated via module.this.enabled ################################################################ +module "log_group_label" { + source = "../tags" + + # Allow forward slashes in log group names (e.g., /service/project/env/stack/name) + regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/" + + context = module.this.context +} + module "log_group" { source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" version = "5.7.2" - create = module.this.enabled && local.create_log_group + create = module.this.enabled name = local.log_group_name retention_in_days = var.retention_in_days kms_key_id = var.kms_key_id + log_group_class = var.log_group_class + skip_destroy = var.skip_destroy - tags = module.this.tags + tags = module.log_group_label.tags } module "log_stream" { + for_each = module.this.enabled ? toset(var.stream_names) : toset([]) + source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" version = "5.7.2" - create = module.this.enabled && local.create_log_stream - - name = local.log_stream_name + create = true + name = each.value log_group_name = module.log_group.cloudwatch_log_group_name } diff --git a/infrastructure/modules/cloudwatch-logs/outputs.tf b/infrastructure/modules/cloudwatch-logs/outputs.tf index dc6dbbc6..b53764db 100644 --- a/infrastructure/modules/cloudwatch-logs/outputs.tf +++ b/infrastructure/modules/cloudwatch-logs/outputs.tf @@ -1,19 +1,25 @@ output "cloudwatch_log_group_name" { - description = "Name of the CloudWatch log group, if created." - value = local.create_log_group ? module.log_group.cloudwatch_log_group_name : null + description = "Name of the CloudWatch log group." + value = module.this.enabled ? module.log_group.cloudwatch_log_group_name : null } output "cloudwatch_log_group_arn" { - description = "ARN of the CloudWatch log group, if created." - value = local.create_log_group ? module.log_group.cloudwatch_log_group_arn : null + description = "ARN of the CloudWatch log group." + value = module.this.enabled ? module.log_group.cloudwatch_log_group_arn : null } -output "cloudwatch_log_stream_name" { - description = "Name of the CloudWatch log stream, if created." - value = local.create_log_stream ? module.log_stream.cloudwatch_log_stream_name : null +output "cloudwatch_log_stream_names" { + description = "Map of CloudWatch log stream names, keyed by stream name." + value = { + for name, stream in module.log_stream : + name => stream.cloudwatch_log_stream_name + } } -output "cloudwatch_log_stream_arn" { - description = "ARN of the CloudWatch log stream, if created." - value = local.create_log_stream ? module.log_stream.cloudwatch_log_stream_arn : null +output "cloudwatch_log_stream_arns" { + description = "Map of CloudWatch log stream ARNs, keyed by stream name." + value = { + for name, stream in module.log_stream : + name => stream.cloudwatch_log_stream_arn + } } diff --git a/infrastructure/modules/cloudwatch-logs/validations.tf b/infrastructure/modules/cloudwatch-logs/validations.tf new file mode 100644 index 00000000..bcf59b5f --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/validations.tf @@ -0,0 +1,28 @@ +################################################################ +# Cross-variable validation constraints for CloudWatch Logs. +# +# These preconditions catch configuration patterns that are either +# inefficient or potentially problematic but would otherwise fail +# silently or produce unexpected behaviour. +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = var.log_group_class == null || var.log_group_class != "INFREQUENT_ACCESS" || var.retention_in_days > 30 + error_message = "INFREQUENT_ACCESS log group class only provides cost savings with retention > 30 days. Use STANDARD for shorter retention periods, or increase retention_in_days to >= 30." + } + + precondition { + condition = var.skip_destroy == null || var.skip_destroy != true || var.retention_in_days >= 30 + error_message = "skip_destroy = true indicates data preservation intent; short retention (<30 days) may conflict with that goal. Consider increasing retention_in_days or setting skip_destroy = false." + } + + precondition { + condition = var.log_group_name == null || can(regex("^/", var.log_group_name)) + error_message = "log_group_name must start with '/' to follow NHS naming convention (e.g., '/////')." + } + } +} diff --git a/infrastructure/modules/cloudwatch-logs/variables.tf b/infrastructure/modules/cloudwatch-logs/variables.tf index 4b2725c9..848f0609 100644 --- a/infrastructure/modules/cloudwatch-logs/variables.tf +++ b/infrastructure/modules/cloudwatch-logs/variables.tf @@ -5,20 +5,20 @@ # context.tf via `module.this`. ################################################################ -variable "create" { +variable "log_group_name" { + description = "Name of the CloudWatch log group. Defaults to `/////` derived from context." type = string - default = "LOG_GROUP_ONLY" - description = "Creation mode for CloudWatch log resources. Valid values are NOTHING, LOG_GROUP_ONLY, and LOG_GROUP_AND_LOG_STREAM." + default = null validation { - condition = contains(["NOTHING", "LOG_GROUP_ONLY", "LOG_GROUP_AND_LOG_STREAM"], var.create) - error_message = "create must be one of: NOTHING, LOG_GROUP_ONLY, LOG_GROUP_AND_LOG_STREAM." + condition = var.log_group_name == null || can(regex("^/.*$"), var.log_group_name) + error_message = "log_group_name must start with a forward slash, e.g. \"/bcss/website/prod/network/loadbalancer\"." } } variable "retention_in_days" { type = number - default = 7 + default = 30 description = "CloudWatch log group retention in days. Valid values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653." validation { @@ -30,5 +30,43 @@ variable "retention_in_days" { variable "kms_key_id" { type = string default = null - description = "Optional customer-managed KMS key ARN for CloudWatch log group encryption. When null, CloudWatch Logs uses AWS-managed encryption. Encryption at rest remains enabled either way." + description = <<-EOT + Optional customer-managed KMS key ARN for CloudWatch log group encryption. + + When null, CloudWatch Logs uses AWS-managed encryption. + + Encryption at rest remains enabled either way. + + Please note, after the AWS KMS CMK is disassociated from the log group, AWS CloudWatch Logs stops encrypting newly ingested data for the log group. + + All previously ingested data remains encrypted, and AWS CloudWatch Logs requires permissions for the CMK whenever the encrypted data is requested. + EOT +} + +variable "stream_names" { + default = [] + type = list(string) + description = "Names of CloudWatch log streams to create within the log group. Empty list creates log group only (recommended for Lambda/ECS auto-managed streams)." + + validation { + condition = alltrue([for s in var.stream_names : can(regex("^[.\\-_/#A-Za-z0-9]+$", s))]) + error_message = "stream_names must contain only alphanumeric characters, '.', '-', '_', '/', and '#'." + } +} + +variable "log_group_class" { + type = string + default = null + description = "Specified the log class of the log group. Valid values are 'STANDARD' or 'INFREQUENT_ACCESS'. When null, defaults to STANDARD. Use INFREQUENT_ACCESS for lower-cost archival of logs accessed infrequently." + + validation { + condition = var.log_group_class == null || contains(["STANDARD", "INFREQUENT_ACCESS"], var.log_group_class) + error_message = "log_group_class must be 'STANDARD', 'INFREQUENT_ACCESS', or null." + } +} + +variable "skip_destroy" { + type = bool + default = null + description = "When true, CloudWatch log group is removed from Terraform state but not deleted at destroy time. Prevents accidental deletion of log groups containing critical audit/compliance data. When null, log group is deleted with the module." } From f7657efe88ba27e4cfd10d87f972b99053d9f25d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 19:44:57 +0100 Subject: [PATCH 05/26] fix(cloudwatch-logs): update AWS provider version and hashes in lock file --- .../cloudwatch-logs/.terraform.lock.hcl | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl index 3211986b..bdc22231 100644 --- a/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl +++ b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl @@ -2,30 +2,29 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.52.0" + version = "6.55.0" constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" hashes = [ - "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", - "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", - "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", - "h1:LmIMOhIeFTfSiUDY4gthZTikT5CqEIGMhgQbv/mdurE=", - "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", - "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", - "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", - "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", - "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", - "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", - "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", - "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", - "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", - "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", - "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", - "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "h1:3OSTaIx7d6T+N6gwNtw0gR+IwExbHqb0y4v529Vey/4=", + "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=", + "h1:QIQoZv637BE40vg7/0gf/5gQbY/ZTngSuJWDl7fJr2s=", + "h1:tBAaeIprhWQmlsq/CoHLXWOLZZYRdCVA1wbNv2co55g=", + "h1:yM6149NHHl8NWU/OCIV2/x6tLrNgDTRKgR8cVrbk9BU=", + "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd", + "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966", + "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6", + "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67", + "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881", + "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5", + "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", - "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", - "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", - "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", - "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45", + "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899", + "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568", + "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34", + "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f", + "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1", + "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a", + "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51", ] } From 76b197bc6ba514e2422b11a66dfb64c650cc522f Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 19:45:14 +0100 Subject: [PATCH 06/26] docs(alb): correct markdown formatting in README for clarity --- infrastructure/modules/alb/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index 4c6e5187..402db778 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -5,7 +5,7 @@ Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terrafo ## What this module enforces | Setting | Value | Reason | -|---|---|---| +| --- | --- | --- | | `create_security_group` | `false` (hardcoded) | Callers **must** pre-create and supply security groups explicitly, enforcing deliberate ingress/egress rule design | | `drop_invalid_header_fields` | `true` (ALB only) | Prevents HTTP header injection attacks on Application Load Balancers | | `enable_deletion_protection` | `true` (default) | Prevents accidental deletion in production; set to `false` for non-production only | @@ -554,7 +554,7 @@ module "alb_custom" { This module enforces several validation constraints via `terraform_data` preconditions. These are checked during `terraform plan` and will fail fast if violated: | Validation | Condition | Resolution | -|---|---|---| +| --- | --- | --- | | **Subnet count** | Consumer-controlled | Subnet count is not enforced at module level, allowing flexibility for development/cost-optimized environments (single AZ) or production (multi-AZ). Consumer should specify based on HA requirements. | | **Internet-facing requires access logs** | `internal == true OR access_logs != null` | Either set `internal = true` for internal LBs, or enable access logging via the `access_logs` variable for internet-facing ALBs/NLBs (compliance requirement). | | **HTTPS listeners require certificate** | Each HTTPS/TLS listener must have `certificate_arn` | Provide a valid ACM certificate ARN in listener configuration. Example: `certificate_arn = data.aws_acm_certificate.selected.arn` | From 4c5f1fc9bb2c680afe18fba4f0c1942493c23a44 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 20:57:10 +0100 Subject: [PATCH 07/26] feat(ecs-cluster): enhance ECS Exec logging with CloudWatch and S3 support, add validation rules, and update documentation --- infrastructure/modules/ecs-cluster/README.md | 241 ++++++++++++++++-- infrastructure/modules/ecs-cluster/locals.tf | 32 ++- infrastructure/modules/ecs-cluster/main.tf | 23 +- .../modules/ecs-cluster/validations.tf | 45 ++++ .../modules/ecs-cluster/variables.tf | 69 +++-- 5 files changed, 348 insertions(+), 62 deletions(-) create mode 100644 infrastructure/modules/ecs-cluster/validations.tf diff --git a/infrastructure/modules/ecs-cluster/README.md b/infrastructure/modules/ecs-cluster/README.md index 8022fdea..92ddd4fe 100644 --- a/infrastructure/modules/ecs-cluster/README.md +++ b/infrastructure/modules/ecs-cluster/README.md @@ -10,10 +10,12 @@ module that enforces the platform's baseline controls. |---|---| |Creation gate|`create = module.this.enabled`| |Naming and tagging|`name` derives from context, `tags = module.this.tags`| -|Logging|ECS Exec logs are configured to use CloudWatch with explicit retention| -|Encryption at rest|Optional KMS encryption for CloudWatch Log Group and ECS Exec sessions| -|No public access by default|Module does not create security groups or ingress rules| -|Operational visibility|Container Insights enabled by default| +|Container Insights|Enabled by default for cluster-level observability (~£0.03–0.04/container/hour)| +|ECS Exec session encryption|`execute_command_kms_key_id` REQUIRED if ECS Exec enabled (session data encrypted)| +|ECS Exec log destinations|CloudWatch Logs and/or S3; at least ONE required if ECS Exec enabled| +|Log destination encryption|Mandatory KMS encryption for CloudWatch Logs and S3 bucket logs| +|No public access by default|Module does not create security groups, ingress rules, or IAM roles| +|Baseline compliance|Encryption, audit trails, and network isolation enforced by design| ## Usage @@ -27,28 +29,49 @@ module "ecs_cluster" { } ``` -### Common production-style +### Common production-style (CloudWatch + S3 dual logging) ```hcl +module "ecs_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" + + context = module.this.context + + log_group_name = "/bcss/ecs/prod/cluster-exec" + retention_in_days = 90 + kms_key_id = module.kms.key_arn + stream_names = ["exec-sessions"] +} + module "ecs_cluster" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-cluster?ref=" context = module.this.context + # CloudWatch destination for ECS Exec logs (mandatory if ECS Exec enabled) + cloudwatch_log_group_name = module.ecs_logs.cloudwatch_log_group_name + cloud_watch_encryption_enabled = true + + # S3 destination for redundant session storage (optional) + s3_bucket_name = module.s3_ecs_exec_logs.bucket_id + s3_bucket_encryption_enabled = true + s3_kms_key_id = module.kms.key_arn + s3_key_prefix = "ecs-exec-logs/" + + # Session-level encryption (always mandatory if ECS Exec enabled) + execute_command_kms_key_id = module.kms.key_arn + + # Capacity provider configuration cluster_capacity_providers = ["FARGATE", "FARGATE_SPOT"] default_capacity_provider_strategy = { FARGATE = { base = 1 - weight = 80 + weight = 70 } FARGATE_SPOT = { - weight = 20 + weight = 30 } } - - cloudwatch_log_group_retention_in_days = 365 - cloudwatch_log_group_kms_key_id = module.kms.key_arn - execute_command_kms_key_id = module.kms.key_arn } ``` @@ -71,15 +94,179 @@ module "ecs_cluster" { ## Conventions -- Name precedence is `cluster_name` first, then context-derived `module.this.id`. -- Caller-provided tags are not accepted directly; all tags are sourced from `module.this.tags`. -- This module focuses on ECS cluster-level configuration and does not manage service/task definitions. +### Naming and Tagging + +- Cluster name precedence: explicit `cluster_name` first, then context-derived `module.this.id` +- All tags sourced from `module.this.tags`; no direct tag parameter accepted +- Naming convention: `---cluster` (derived from context) + +### Container Insights + +Container Insights is **enabled by default** (`enable_container_insights = true`) for cluster-level observability: + +- **Cost impact**: ~£0.03–0.04/container/hour (typical ECS cluster) +- **Metrics collected**: CPU, memory, disk, network utilization per container +- **Log destination**: CloudWatch Logs (`/aws/ecs/containerinsights//performance`) +- **Retention**: 30 days (separate from ECS Exec logs) +- **Disable if**: Testing or non-critical development environments (use `enable_container_insights = false`) + +### ECS Exec: Three Logging Levels + +When `enable_execute_command = true`, sessions can be logged at three levels: + +| Level | How | Storage | Encryption | Use Case | +|-------|-----|---------|------------|----------| +| **Container Insights** | Cluster metric collection | CloudWatch Logs | AWS-managed key | Operational metrics (CPU, memory) | +| **ECS Exec sessions** | Caller command/output | CloudWatch Logs AND/OR S3 | ✅ Mandatory KMS | Audit trail, troubleshooting | +| **Task application logs** | Application stdout/stderr | Configured per task definition | Per-task driver config | Debugging, monitoring | + +### ECS Exec: Encryption and Destinations + +When `enable_execute_command = true`: + +- **Session encryption** (`execute_command_kms_key_id`): **REQUIRED**. All session data encrypted with this KMS key. +- **Log destinations**: At least ONE required: + - **CloudWatch Logs**: `cloudwatch_log_group_name` + `cloud_watch_encryption_enabled` + - **S3**: `s3_bucket_name` + `s3_bucket_encryption_enabled` + `s3_kms_key_id` + - **Both**: Supported simultaneously for redundancy (recommended for production) + +### ECS Exec Log Group Management + +- **CloudWatch log group**: Pre-created externally via `cloudwatch-logs` module (not inline by this module) +- **Retention baseline**: 90 days (exceeds AWS 30-day audit minimum) +- **KMS encryption**: Mandatory for security compliance +- **Log group class**: Optional (`STANDARD` or `INFREQUENT_ACCESS` for cost optimization) + +### Caller Responsibilities + +This module handles **cluster-level** configuration only. Callers are responsible for: + +1. **Security groups**: Create and manage via dedicated SG module + - Ingress: Allow ephemeral ports (32768–65535) from ALB/NLB task target groups + - Egress: Allow HTTPS 443 (ECR, CloudWatch), UDP 53 (DNS) + +2. **CloudWatch log group** (if using CloudWatch for ECS Exec): Create via `cloudwatch-logs` module + - Must have KMS encryption enabled + - Must use `/path/format` naming convention + +3. **S3 bucket** (if using S3 for ECS Exec): Create via `s3-bucket` module + - Must have encryption enabled + - Must have KMS key specified for ECS Exec logs + +4. **IAM roles**: Create `task-exec` and `infrastructure` roles in caller stack + - Module does not create these (enforced by `create_*_iam_role = false`) + +5. **Task definitions and services**: Define separately (outside this module) + +### Disabling ECS Exec + +If `enable_execute_command = false`: + +- No log group, S3 bucket, or session encryption needed +- ECS Exec is unavailable for this cluster +- Container Insights still available (independent setting) + +--- + +## Logging Architecture: Best Practices + +### Why CloudWatch + S3 Together? + +**CloudWatch Logs** (real-time access): + +- Immediate visibility of ECS Exec sessions +- CloudWatch Logs Insights queries for troubleshooting +- Cost: Low volume for typical session logs (< £0.50/month) + +**S3** (long-term archive): + +- Durable, immutable audit trail +- Cost-effective retention (Glacier for old sessions) +- Compliance: HIPAA, SOC 2 audit requirements +- Search: Athena queries across historical logs + +**Combined approach** (recommended): + +```hcl +# Real-time: CloudWatch +cloudwatch_log_group_name = module.ecs_logs.cloudwatch_log_group_name +cloud_watch_encryption_enabled = true + +# Archive: S3 +s3_bucket_name = module.s3_archive.bucket_id +s3_bucket_encryption_enabled = true +s3_kms_key_id = module.kms.key_arn +``` + +### IAM Policy Baseline (Task Execution Role) + +The `task-exec` IAM role must include permissions for: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:REGION:ACCOUNT:log-group:/aws/ecs/*" + }, + { + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "kms:Decrypt" + ], + "Resource": "arn:aws:kms:REGION:ACCOUNT:key/KEY_ID" + } + ] +} +``` + +--- ## What this module does NOT do -- Create ECS services, task definitions, or autoscaling policies. -- Create security groups, infrastructure IAM roles, or managed instance profiles. -- Validate application-level network policy for tasks/services. +- **Does not create security groups or manage ingress/egress rules.** Callers must create a security group and pass it to ECS services/tasks. Required rules: + - Ingress: Ephemeral ports (32768–65535) from ALB/NLB + - Egress: HTTPS 443 (ECR, CloudWatch), UDP 53 (DNS), optional HTTPS 443 to other services + +- **Does not create or manage IAM roles/policies.** Callers must create: + - `ecs:task-exec` role for task execution (pull images, write logs, decrypt KMS) + - `ecs:infrastructure` role for managed instances (EC2 lifecycle) + - `ecs:node` role for managed instance node IAM profile + +- **Does not configure task-level application logging.** Callers specify logging drivers in task definitions: + - `awslogs`: CloudWatch Logs (common) + - `splunk`: Splunk HTTP Event Collector + - `awsfirelens`: Route logs via Fluent Bit/Logstash + +- **Does not create CloudWatch alarms.** Alarms (CPU, memory, task failures) are a separate responsibility. Consider: + - Task count alarms (desired vs. running) + - CPU/memory utilization alarms + - ECS service deployment alarms + +- **Does not configure Service Connect.** The optional `service_connect_defaults` parameter allows consumers to enable it; this module does not enforce or configure it. + +- **Does not manage scaling policies.** Auto-scaling (target-tracking, step-based) is configured at the service or capacity provider level, not the cluster. + +- **Does not validate external resources (security groups, log groups, KMS keys).** Callers must ensure these resources exist and have correct policies before applying this module. + +- **Does not configure cluster auto-scaling** (e.g., managed instances scale-in protection, warm pool sizing). These are handled by capacity provider or ASG configuration outside this module. + +--- @@ -94,7 +281,9 @@ module "ecs_cluster" { ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -105,7 +294,9 @@ No providers. ## Resources -No resources. +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs @@ -115,10 +306,8 @@ No resources. | [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 | -| [cloudwatch\_log\_group\_class](#input\_cloudwatch\_log\_group\_class) | Optional CloudWatch Log Group class. Valid values are STANDARD or INFREQUENT\_ACCESS. | `string` | `null` | no | -| [cloudwatch\_log\_group\_kms\_key\_id](#input\_cloudwatch\_log\_group\_kms\_key\_id) | Optional KMS key ARN used to encrypt the CloudWatch Log Group. | `string` | `null` | no | -| [cloudwatch\_log\_group\_name](#input\_cloudwatch\_log\_group\_name) | Optional CloudWatch Log Group name for ECS Exec logs. Defaults to /aws/ecs//execute-command. | `string` | `null` | no | -| [cloudwatch\_log\_group\_retention\_in\_days](#input\_cloudwatch\_log\_group\_retention\_in\_days) | CloudWatch Log Group retention period in days for ECS Exec logs. | `number` | `90` | no | +| [cloud\_watch\_encryption\_enabled](#input\_cloud\_watch\_encryption\_enabled) | Whether to enable encryption for ECS Exec logs stored in CloudWatch Logs. Encryption is mandatory when using CloudWatch destination. | `bool` | `true` | no | +| [cloudwatch\_log\_group\_name](#input\_cloudwatch\_log\_group\_name) | Optional name of a pre-created CloudWatch Log Group for ECS Exec session logs.
The log group must be created separately via the cloudwatch-logs module.
Either cloudwatch\_log\_group\_name or s3\_bucket\_name must be provided
if enable\_execute\_command is true.
Example: /bcss/app/prod/ecs/cluster-exec-logs | `string` | `null` | no | | [cluster\_capacity\_providers](#input\_cluster\_capacity\_providers) | Capacity provider names to associate with the ECS cluster. | `list(string)` |
[
"FARGATE"
]
| no | | [cluster\_name](#input\_cluster\_name) | Optional ECS cluster name. When null, this module uses module.this.id. | `string` | `null` | 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 | @@ -131,7 +320,7 @@ No resources. | [enable\_execute\_command](#input\_enable\_execute\_command) | Whether to enable ECS Exec configuration at cluster level. | `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 | -| [execute\_command\_kms\_key\_id](#input\_execute\_command\_kms\_key\_id) | Optional KMS key ARN or ID for ECS Exec session data encryption. | `string` | `null` | no | +| [execute\_command\_kms\_key\_id](#input\_execute\_command\_kms\_key\_id) | KMS key ARN or ID for encrypting ECS Exec session data. Required if enable\_execute\_command is true. | `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 | | [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 | @@ -144,6 +333,10 @@ 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 | +| [s3\_bucket\_encryption\_enabled](#input\_s3\_bucket\_encryption\_enabled) | Whether to enforce encryption on the S3 bucket used for ECS Exec session logs.
If s3\_bucket\_name is provided, this must be set to true.
Encryption is mandatory for ECS Exec session data. | `bool` | `null` | no | +| [s3\_bucket\_name](#input\_s3\_bucket\_name) | Optional S3 bucket name for ECS Exec session logs.
Either cloudwatch\_log\_group\_name or s3\_bucket\_name must be provided
if enable\_execute\_command is true. If specified, s3\_bucket\_encryption\_enabled
must be set to true (encryption is mandatory).
Example: bcss-prod-ecs-exec-logs | `string` | `null` | no | +| [s3\_key\_prefix](#input\_s3\_key\_prefix) | Optional S3 key prefix for storing ECS Exec session logs.
All session logs will be stored under this prefix in the S3 bucket.
If not provided, logs are stored at the bucket root.
Example: ecs-exec-logs/ or prod/ecs-exec/ | `string` | `null` | no | +| [s3\_kms\_key\_id](#input\_s3\_kms\_key\_id) | Optional KMS key ARN or ID to use for encrypting ECS Exec session logs in S3.
Only relevant if s3\_bucket\_name is provided and s3\_bucket\_encryption\_enabled is true.
Example: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 | `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 | | [service\_connect\_defaults](#input\_service\_connect\_defaults) | Optional default Service Connect namespace configuration. |
object({
namespace = string
})
| `null` | no | diff --git a/infrastructure/modules/ecs-cluster/locals.tf b/infrastructure/modules/ecs-cluster/locals.tf index 0f413811..33862ff1 100644 --- a/infrastructure/modules/ecs-cluster/locals.tf +++ b/infrastructure/modules/ecs-cluster/locals.tf @@ -2,8 +2,7 @@ locals { # Derive the cluster name from context when no explicit override is provided. cluster_name = var.cluster_name != null ? var.cluster_name : module.this.id - cloudwatch_log_group_name = var.cloudwatch_log_group_name != null ? var.cloudwatch_log_group_name : "/aws/ecs/${local.cluster_name}/execute-command" - + # Container Insights setting, enabled by default for observability cluster_settings = var.enable_container_insights ? [ { name = "containerInsights" @@ -11,14 +10,27 @@ locals { } ] : [] + # ECS Exec log_configuration block (nested within execute_command_configuration) + # Maps to: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster#log_configuration + log_configuration = var.enable_execute_command ? { + cloud_watch_encryption_enabled = var.cloud_watch_encryption_enabled + cloud_watch_log_group_name = var.cloudwatch_log_group_name + s3_bucket_encryption_enabled = var.s3_bucket_encryption_enabled + s3_bucket_name = var.s3_bucket_name + s3_kms_key_id = var.s3_kms_key_id + s3_key_prefix = var.s3_key_prefix + } : null + + # ECS Exec execute_command_configuration block + # Maps to: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster#execute_command_configuration + execute_command_configuration = var.enable_execute_command ? { + kms_key_id = var.execute_command_kms_key_id + log_configuration = local.log_configuration + logging = "OVERRIDE" + } : null + + # Cluster-level configuration block for upstream module cluster_configuration = var.enable_execute_command ? { - execute_command_configuration = { - kms_key_id = var.execute_command_kms_key_id - logging = "OVERRIDE" - log_configuration = { - cloud_watch_encryption_enabled = var.cloudwatch_log_group_kms_key_id != null - cloud_watch_log_group_name = local.cloudwatch_log_group_name - } - } + execute_command_configuration = local.execute_command_configuration } : null } diff --git a/infrastructure/modules/ecs-cluster/main.tf b/infrastructure/modules/ecs-cluster/main.tf index 752e1c15..974e21d7 100644 --- a/infrastructure/modules/ecs-cluster/main.tf +++ b/infrastructure/modules/ecs-cluster/main.tf @@ -7,7 +7,11 @@ # * Creation is gated by module.this.enabled # * Naming and tagging are enforced via context.tf # * CloudWatch Container Insights enabled by default -# * ECS Exec logging uses an explicit CloudWatch Log Group +# * ECS Exec logging supports two destination options (KMS encrypted): +# - CloudWatch Logs: Log group created separately via cloudwatch-logs module +# - S3: Bucket with encryption enabled (managed by caller) +# * At least one log destination REQUIRED if ECS Exec enabled +# * Cross-variable validation rules enforced in validations.tf # # Naming and tagging are derived from context.tf via module.this. ################################################################ @@ -23,19 +27,20 @@ module "ecs_cluster" { configuration = local.cluster_configuration - # The CloudWatch Log Group is created only when ECS Exec is enabled. - # Consider moving the CloudWatch Log Group creation to a separate module - # so that it can be created independently of the ECS cluster. - create_cloudwatch_log_group = var.enable_execute_command - cloudwatch_log_group_name = local.cloudwatch_log_group_name - cloudwatch_log_group_kms_key_id = var.cloudwatch_log_group_kms_key_id - cloudwatch_log_group_retention_in_days = var.cloudwatch_log_group_retention_in_days - cloudwatch_log_group_class = var.cloudwatch_log_group_class + # CloudWatch Log Group is optional (if using CloudWatch for ECS Exec logs). + # When provided, it MUST be pre-created via the cloudwatch-logs module. + # Do not create it here; only reference the pre-created log group. + # Encryption (kms_key_id) is mandatory for security compliance. + # S3 logging is supported as an alternative. See validations.tf for constraints. + create_cloudwatch_log_group = false cluster_capacity_providers = var.cluster_capacity_providers default_capacity_provider_strategy = var.default_capacity_provider_strategy service_connect_defaults = var.service_connect_defaults + # Container Insights setting is configured in local.cluster_settings + # based on var.enable_container_insights; no need to set it again here. + # Keep the module focused on the cluster itself; callers should # manage IAM/security group resources separately in dedicated modules. create_security_group = false diff --git a/infrastructure/modules/ecs-cluster/validations.tf b/infrastructure/modules/ecs-cluster/validations.tf new file mode 100644 index 00000000..c7b0bdfd --- /dev/null +++ b/infrastructure/modules/ecs-cluster/validations.tf @@ -0,0 +1,45 @@ +################################################################ +# Cross-variable validation constraints for ECS cluster. +# +# These preconditions enforce: +# * When ECS Exec is enabled: +# - At least one log destination (CloudWatch and/or S3) required +# - Session encryption (execute_command_kms_key_id) always required +# * If CloudWatch destination: cloud_watch_encryption_enabled must be true +# * If S3 destination: s3_bucket_encryption_enabled must be true, s3_kms_key_id required +# * Both CloudWatch and S3 can be enabled simultaneously for redundancy +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = !var.enable_execute_command || (var.execute_command_kms_key_id != null && var.execute_command_kms_key_id != "") + error_message = "When enable_execute_command = true, execute_command_kms_key_id is REQUIRED. ECS Exec session data must be encrypted. Provide a KMS key ARN or ID for session encryption." + } + + precondition { + condition = !var.enable_execute_command || ( + (var.cloudwatch_log_group_name != null && var.cloudwatch_log_group_name != "") || + (var.s3_bucket_name != null && var.s3_bucket_name != "") + ) + error_message = "When enable_execute_command = true, at least one log destination is REQUIRED: cloudwatch_log_group_name and/or s3_bucket_name. Both can be configured simultaneously for redundancy." + } + + precondition { + condition = var.cloudwatch_log_group_name == null || var.cloud_watch_encryption_enabled == true + error_message = "When cloudwatch_log_group_name is provided, cloud_watch_encryption_enabled must be true. CloudWatch log encryption is mandatory for ECS Exec sessions." + } + + precondition { + condition = var.s3_bucket_name == null || var.s3_bucket_name == "" || var.s3_bucket_encryption_enabled == true + error_message = "When s3_bucket_name is provided, s3_bucket_encryption_enabled must be set to true. S3 encryption is mandatory for ECS Exec session logs." + } + + precondition { + condition = !var.s3_bucket_encryption_enabled || (var.s3_kms_key_id != null && var.s3_kms_key_id != "") + error_message = "When s3_bucket_encryption_enabled = true, s3_kms_key_id is REQUIRED. Provide a KMS key ARN or ID for S3 encryption of ECS Exec session logs." + } + } +} diff --git a/infrastructure/modules/ecs-cluster/variables.tf b/infrastructure/modules/ecs-cluster/variables.tf index 9673c3c3..81f46c56 100644 --- a/infrastructure/modules/ecs-cluster/variables.tf +++ b/infrastructure/modules/ecs-cluster/variables.tf @@ -29,43 +29,74 @@ variable "enable_execute_command" { } variable "execute_command_kms_key_id" { - description = "Optional KMS key ARN or ID for ECS Exec session data encryption." + description = "KMS key ARN or ID for encrypting ECS Exec session data. Required if enable_execute_command is true." type = string default = null } +variable "cloud_watch_encryption_enabled" { + description = "Whether to enable encryption for ECS Exec logs stored in CloudWatch Logs. Encryption is mandatory when using CloudWatch destination." + type = bool + default = true +} + variable "cloudwatch_log_group_name" { - description = "Optional CloudWatch Log Group name for ECS Exec logs. Defaults to /aws/ecs//execute-command." + description = <<-EOT + Optional name of a pre-created CloudWatch Log Group for ECS Exec session logs. + The log group must be created separately via the cloudwatch-logs module. + Either cloudwatch_log_group_name or s3_bucket_name must be provided + if enable_execute_command is true. + Example: /bcss/app/prod/ecs/cluster-exec-logs + EOT type = string default = null } -variable "cloudwatch_log_group_kms_key_id" { - description = "Optional KMS key ARN used to encrypt the CloudWatch Log Group." +################################################################ +# ECS Exec S3 logging +################################################################ + +variable "s3_bucket_name" { + description = <<-EOT + Optional S3 bucket name for ECS Exec session logs. + Either cloudwatch_log_group_name or s3_bucket_name must be provided + if enable_execute_command is true. If specified, s3_bucket_encryption_enabled + must be set to true (encryption is mandatory). + Example: bcss-prod-ecs-exec-logs + EOT type = string default = null } -variable "cloudwatch_log_group_retention_in_days" { - description = "CloudWatch Log Group retention period in days for ECS Exec logs." - type = number - default = 90 - - validation { - condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653], var.cloudwatch_log_group_retention_in_days) - error_message = "cloudwatch_log_group_retention_in_days must be a valid CloudWatch retention value." - } +variable "s3_bucket_encryption_enabled" { + description = <<-EOT + Whether to enforce encryption on the S3 bucket used for ECS Exec session logs. + If s3_bucket_name is provided, this must be set to true. + Encryption is mandatory for ECS Exec session data. + EOT + type = bool + default = null } -variable "cloudwatch_log_group_class" { - description = "Optional CloudWatch Log Group class. Valid values are STANDARD or INFREQUENT_ACCESS." +variable "s3_kms_key_id" { + description = <<-EOT + Optional KMS key ARN or ID to use for encrypting ECS Exec session logs in S3. + Only relevant if s3_bucket_name is provided and s3_bucket_encryption_enabled is true. + Example: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 + EOT type = string default = null +} - validation { - condition = var.cloudwatch_log_group_class == null ? true : contains(["STANDARD", "INFREQUENT_ACCESS"], var.cloudwatch_log_group_class) - error_message = "cloudwatch_log_group_class must be STANDARD, INFREQUENT_ACCESS, or null." - } +variable "s3_key_prefix" { + description = <<-EOT + Optional S3 key prefix for storing ECS Exec session logs. + All session logs will be stored under this prefix in the S3 bucket. + If not provided, logs are stored at the bucket root. + Example: ecs-exec-logs/ or prod/ecs-exec/ + EOT + type = string + default = null } variable "cluster_capacity_providers" { From eaeee5544522dd7c61dbf95a982a850710910289 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 21:12:28 +0100 Subject: [PATCH 08/26] fix(cloudwatch-logs): update log group creation logic to use log_group_label.enabled --- infrastructure/modules/cloudwatch-logs/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/cloudwatch-logs/main.tf b/infrastructure/modules/cloudwatch-logs/main.tf index 001a85f1..5c2dffc6 100644 --- a/infrastructure/modules/cloudwatch-logs/main.tf +++ b/infrastructure/modules/cloudwatch-logs/main.tf @@ -27,7 +27,7 @@ module "log_group" { source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" version = "5.7.2" - create = module.this.enabled + create = module.log_group_label.enabled name = local.log_group_name retention_in_days = var.retention_in_days @@ -39,7 +39,7 @@ module "log_group" { } module "log_stream" { - for_each = module.this.enabled ? toset(var.stream_names) : toset([]) + for_each = module.log_group_label.enabled ? toset(var.stream_names) : toset([]) source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" version = "5.7.2" From 7163156070b531738a66e2fca3c0f90d347148f2 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 21:14:02 +0100 Subject: [PATCH 09/26] feat(ssm-parameter): enhance parameter naming with optional override and improved validation --- .../modules/ssm-parameter/README.md | 3 ++- .../modules/ssm-parameter/locals.tf | 10 ++------- infrastructure/modules/ssm-parameter/main.tf | 22 ++++++++++++++++--- .../modules/ssm-parameter/variables.tf | 8 +++---- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/infrastructure/modules/ssm-parameter/README.md b/infrastructure/modules/ssm-parameter/README.md index 81e3b3a6..b333bf6f 100644 --- a/infrastructure/modules/ssm-parameter/README.md +++ b/infrastructure/modules/ssm-parameter/README.md @@ -97,6 +97,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | +| [ssm\_param\_label](#module\_ssm\_param\_label) | ../tags | n/a | | [ssm\_parameter](#module\_ssm\_parameter) | terraform-aws-modules/ssm-parameter/aws | 2.1.0 | | [this](#module\_this) | ../tags | n/a | @@ -132,7 +133,7 @@ No resources. | [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | | [overwrite](#input\_overwrite) | Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform | `bool` | `null` | no | | [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | -| [path](#input\_path) | Path part of the name of the parameter. Defaults to `///` derived from context. | `string` | `null` | no | +| [parameter\_name](#input\_parameter\_name) | Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`. | `string` | `null` | 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 | diff --git a/infrastructure/modules/ssm-parameter/locals.tf b/infrastructure/modules/ssm-parameter/locals.tf index bc44a012..d32a3994 100644 --- a/infrastructure/modules/ssm-parameter/locals.tf +++ b/infrastructure/modules/ssm-parameter/locals.tf @@ -1,10 +1,4 @@ locals { - default_path = format( - "/%s/%s/%s", - module.this.service, - module.this.project, - module.this.environment - ) - path = var.path != null ? var.path : local.default_path - name = "${trimsuffix(local.path, "/")}/${module.this.id}" + # Parameter name: use custom override if provided, otherwise derive from context + parameter_name = var.parameter_name != null ? var.parameter_name : module.ssm_param_label.id } diff --git a/infrastructure/modules/ssm-parameter/main.tf b/infrastructure/modules/ssm-parameter/main.tf index b0dc9fa4..d5ab402e 100644 --- a/infrastructure/modules/ssm-parameter/main.tf +++ b/infrastructure/modules/ssm-parameter/main.tf @@ -1,10 +1,26 @@ +################################################################ +# SSM Parameter with context-derived naming and tagging +# +# This module uses the label/tags module to generate hierarchical +# parameter names with forward slashes (e.g., /service/project/env/stack/name) +################################################################ + +module "ssm_param_label" { + source = "../tags" + + # Allow forward slashes for hierarchical parameter names + regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/" + + context = module.this.context +} + module "ssm_parameter" { source = "terraform-aws-modules/ssm-parameter/aws" version = "2.1.0" - create = module.this.enabled - name = local.name - tags = module.this.tags + create = module.ssm_param_label.enabled + name = local.parameter_name + tags = module.ssm_param_label.tags allowed_pattern = var.allowed_pattern data_type = var.ssm_data_type diff --git a/infrastructure/modules/ssm-parameter/variables.tf b/infrastructure/modules/ssm-parameter/variables.tf index a676a752..42d330ff 100644 --- a/infrastructure/modules/ssm-parameter/variables.tf +++ b/infrastructure/modules/ssm-parameter/variables.tf @@ -39,14 +39,14 @@ variable "overwrite" { default = null } -variable "path" { - description = "Path part of the name of the parameter. Defaults to `///` derived from context." +variable "parameter_name" { + description = "Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`." type = string default = null validation { - condition = var.path == null || can(regex("^/", coalesce(var.path, "/"))) - error_message = "path must start with a forward slash, e.g. \"/bcss\"." + condition = var.parameter_name == null || can(regex("^/", coalesce(var.parameter_name, "/"))) + error_message = "parameter_name must start with a forward slash, e.g. \"/bcss/prod/myapp/config\"." } } From b9b4db7387daf9485de6ef52bf36f854e6732ca1 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 21:52:28 +0100 Subject: [PATCH 10/26] feat(ssm-parameter): enhance parameter naming with optional override and improved validation --- .../modules/secrets-manager/README.md | 5 +++-- .../modules/secrets-manager/locals.tf | 7 +++---- .../modules/secrets-manager/main.tf | 19 ++++++++++++++----- .../modules/secrets-manager/validations.tf | 2 +- .../modules/secrets-manager/variables.tf | 7 ++++--- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/infrastructure/modules/secrets-manager/README.md b/infrastructure/modules/secrets-manager/README.md index fcb530f6..04858c29 100644 --- a/infrastructure/modules/secrets-manager/README.md +++ b/infrastructure/modules/secrets-manager/README.md @@ -115,7 +115,7 @@ module "rotated_password" { - `random_password_override_special` overrides the default special character set used during password generation. - `rotate_immediately` controls whether rotation fires immediately (`true`) or waits for the next scheduled window (`false`); only relevant when `enable_rotation = true`. - `create_policy` defaults to `false`; set to `true` and provide `policy_statements` to attach a resource-based policy. -- Secret names are derived from `module.this.id` for consistency with other screening modules. +- Secret names are derived from `module.secret_label.id` (hierarchical, forward-slash safe) for consistency with other screening modules. ## What this module does NOT do @@ -153,6 +153,7 @@ The following constraints are enforced at `plan` time via preconditions in `vali | Name | Source | Version | | ---- | ------ | ------- | | [secret](#module\_secret) | terraform-aws-modules/secrets-manager/aws | 2.1.0 | +| [secret\_label](#module\_secret\_label) | ../tags | n/a | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -201,7 +202,7 @@ The following constraints are enforced at `plan` time via preconditions in `vali | [rotate\_immediately](#input\_rotate\_immediately) | When enable\_rotation is true, specifies whether to rotate the secret immediately or wait until the next scheduled rotation window. Defaults to immediate rotation when not set. | `bool` | `null` | no | | [rotation\_lambda\_arn](#input\_rotation\_lambda\_arn) | ARN of the Lambda function that rotates the secret. Required when enable\_rotation is true. | `string` | `""` | no | | [rotation\_rules](#input\_rotation\_rules) | Rotation schedule for the secret. Provide either automatically\_after\_days or a schedule\_expression (cron/rate). Required when enable\_rotation is true. |
object({
automatically_after_days = optional(number)
duration = optional(string)
schedule_expression = optional(string)
})
| `null` | no | -| [secret\_name](#input\_secret\_name) | Optional explicit name for the secret. When null, the name is derived from context labels via module.this.id. | `string` | `null` | no | +| [secret\_name](#input\_secret\_name) | Optional explicit name for the secret. When null, the name is derived from context labels via module.secret\_label.id. | `string` | `null` | no | | [secret\_string](#input\_secret\_string) | The secret value to store as a plaintext string. Use jsonencode() to store structured data such as database credentials. Mutually exclusive with secret\_string\_wo. | `string` | `null` | no | | [secret\_string\_wo](#input\_secret\_string\_wo) | Write-only variant of secret\_string. The value is accepted by Terraform but never stored in state, making it safer for production secrets. Use jsonencode() for structured data. Mutually exclusive with secret\_string. | `string` | `null` | no | | [secret\_string\_wo\_version](#input\_secret\_string\_wo\_version) | Trigger value used alongside secret\_string\_wo. Increment this (e.g. a timestamp or counter) whenever the secret value changes, so Terraform knows to push the new value. | `string` | `null` | no | diff --git a/infrastructure/modules/secrets-manager/locals.tf b/infrastructure/modules/secrets-manager/locals.tf index 2b565bce..a84ff4d8 100644 --- a/infrastructure/modules/secrets-manager/locals.tf +++ b/infrastructure/modules/secrets-manager/locals.tf @@ -1,6 +1,5 @@ locals { - # Use the caller-supplied secret name if provided; otherwise fall back - # to the auto-generated context ID. The caller controls the generated - # name by setting context labels: service, environment, stack, name, etc. - secret_name = var.secret_name != null ? var.secret_name : module.this.id + # Secret name: use custom override if provided, otherwise derive from context + # labels via module.secret_label.id (e.g. "bcss-screening-test-db-credentials") + secret_name = var.secret_name != null ? var.secret_name : module.secret_label.id } diff --git a/infrastructure/modules/secrets-manager/main.tf b/infrastructure/modules/secrets-manager/main.tf index c484a466..a8f2ec68 100644 --- a/infrastructure/modules/secrets-manager/main.tf +++ b/infrastructure/modules/secrets-manager/main.tf @@ -4,22 +4,31 @@ # Thin NHS wrapper around the community secrets-manager module # that enforces the screening platform's baseline controls: # -# * Naming: derived from context labels via module.this.id -# * Tagging: all NHS-required tags applied automatically +# * Naming: derived from context labels via module.secret_label.id +# * Tagging: all NHS-required tags applied via module.secret_label.tags # * Public policy: always blocked (block_public_policy = true) -# * Enabled flag: create = module.this.enabled +# * Enabled flag: create = module.secret_label.enabled # # Inputs intentionally NOT exposed (hardcoded below): # - block_public_policy → always true; callers cannot override # # Cross-variable input constraints are enforced in validations.tf. ################################################################ +module "secret_label" { + source = "../tags" + + # Allow forward slashes for hierarchical secret names + regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/" + + context = module.this.context +} + module "secret" { source = "terraform-aws-modules/secrets-manager/aws" version = "2.1.0" - create = module.this.enabled + create = module.secret_label.enabled # Naming — always derived from context labels name = local.secret_name @@ -49,5 +58,5 @@ module "secret" { rotation_rules = var.rotation_rules # Tags — automatically populated from context - tags = module.this.tags + tags = module.secret_label.tags } diff --git a/infrastructure/modules/secrets-manager/validations.tf b/infrastructure/modules/secrets-manager/validations.tf index 0a758f1f..d7d7597e 100644 --- a/infrastructure/modules/secrets-manager/validations.tf +++ b/infrastructure/modules/secrets-manager/validations.tf @@ -11,7 +11,7 @@ ################################################################ resource "terraform_data" "validations" { - count = module.this.enabled ? 1 : 0 + count = module.secret_label.enabled ? 1 : 0 lifecycle { precondition { diff --git a/infrastructure/modules/secrets-manager/variables.tf b/infrastructure/modules/secrets-manager/variables.tf index 2c47affd..50b773aa 100644 --- a/infrastructure/modules/secrets-manager/variables.tf +++ b/infrastructure/modules/secrets-manager/variables.tf @@ -2,11 +2,12 @@ # Secrets Manager-specific inputs. # # Naming, tagging and the master `enabled` switch come from -# context.tf via `module.this`. +# context.tf via `module.this`, and are processed by `module.secret_label` +# to provide hierarchical naming support (forward-slash safe). # # Inputs NOT exposed here (opinionated defaults hardcoded in main.tf): # - block_public_policy → always true -# - name / name_prefix → derived from module.this.id via locals.tf +# - name / name_prefix → derived from module.secret_label.id via locals.tf # - secret_binary → not supported; use secret_string # - replica → replication not required for this use case # - version_stages → low-value for standard use cases @@ -19,7 +20,7 @@ variable "secret_name" { type = string default = null - description = "Optional explicit name for the secret. When null, the name is derived from context labels via module.this.id." + description = "Optional explicit name for the secret. When null, the name is derived from context labels via module.secret_label.id." } ################################################################ From 32cbd362d89b6e464815f095057f7d780f2ae4b3 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 21 Jul 2026 22:25:52 +0100 Subject: [PATCH 11/26] fix(ssm-parameter): update README and module documentation for clarity, add input validation logic --- .../modules/ssm-parameter/README.md | 231 +++++++++++++++--- infrastructure/modules/ssm-parameter/main.tf | 13 +- .../modules/ssm-parameter/validations.tf | 31 +++ .../modules/ssm-parameter/variables.tf | 6 +- 4 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 infrastructure/modules/ssm-parameter/validations.tf diff --git a/infrastructure/modules/ssm-parameter/README.md b/infrastructure/modules/ssm-parameter/README.md index b333bf6f..db4e0353 100644 --- a/infrastructure/modules/ssm-parameter/README.md +++ b/infrastructure/modules/ssm-parameter/README.md @@ -1,28 +1,29 @@ # SSM Parameter -NHS Screening wrapper around the community +Thin NHS wrapper around the community [`terraform-aws-modules/ssm-parameter/aws`](https://registry.terraform.io/modules/terraform-aws-modules/ssm-parameter/aws/latest) -module that consumes the shared `context.tf` for naming and tagging. +module that enforces the screening platform's baseline controls for parameter naming, tagging, and encryption. ## What this module enforces -| Control | How it is enforced | -| --- | --- | -| Naming consistency | Parameter name is derived from context labels; path-style names are normalised to start with `/` | -| Tagging consistency | Tags are always sourced from `module.this.tags` | -| SecureString guardrail | `key_id` is mandatory when `type = "SecureString"` via variable validation | +| Control | How it is enforced | Impact | +| --- | --- | --- | +| Naming consistency | Parameter name is derived from context labels; path-style names always start with `/` | Ensures hierarchical organisation across teams | +| Tagging consistency | All tags sourced from `module.this.tags` (NHS-standard set) | Ensures billing, compliance, and governance controls | +| KMS encryption for secrets | `key_id` is **mandatory** when `type = "SecureString"` | No unencrypted secrets in SSM; prevents accidental exposure | +| Sensitive value protection | `value` and `values` marked `sensitive = true` in Terraform | Prevents secrets from appearing in logs/state diffs | +| Creation gating | `create = module.ssm_param_label.enabled` | Allows disabling entire module via context | ## Usage -### Minimal string parameter +### 1. Minimal string parameter ```hcl module "app_config_parameter" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" - service = "bcss" - project = "api" - environment = "development" + context = module.this.context + stack = "api" name = "log-level" type = "String" @@ -30,54 +31,202 @@ module "app_config_parameter" { } ``` -### Production SecureString parameter with customer-managed KMS key +### 2. JSON-encoded SecureString parameter with KMS encryption (production) + +Stores structured secrets (e.g., database credentials) encrypted with a customer-managed KMS key: + +```hcl +module "database_credentials" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + stack = "database" + name = "credentials" + + type = "SecureString" + key_id = module.kms.key_arn # Required — KMS key is mandatory for SecureString + + # Store structured secrets as JSON — safer than flat strings + value = jsonencode({ + engine = "postgres" + host = module.rds.endpoint + port = 5432 + username = "admin" + password = var.db_admin_password # Use var with sensitive = true + }) + + description = "RDS Aurora credentials (JSON format)" +} +``` + +Output usage in consumer stack: ```hcl -module "database_password_parameter" { +locals { + db_creds = jsondecode(module.ssm.parameter_value) +} + +resource "aws_db_instance" "main" { + db_name = "screening_db" + username = local.db_creds.username + password = local.db_creds.password + # ... +} +``` + +### 3. API key as SecureString + +```hcl +module "third_party_api_key" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" - service = "bcss" - project = "database" - environment = "prod" - name = "db-password" + context = module.this.context + stack = "integration" + name = "third-party-api-key" type = "SecureString" - value = var.db_password key_id = module.kms.key_arn + + value = var.api_key # Passed from vars with sensitive = true + description = "API key for third-party supplier integration (encrypted with KMS)" + + # Set to true when rotation Lambda updates the value outside Terraform + ignore_value_changes = true } ``` -### Path-style parameter with value change ignored +### 4. StringList parameter (comma-separated values) ```hcl -module "shared_api_endpoint" { +module "allowed_ip_addresses" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" - service = "bcss" - project = "platform" - environment = "prod" - name = "shared/api/base-url" + context = module.this.context + stack = "security" + name = "allowed-ips" + + type = "StringList" + values = ["10.0.0.0/8", "192.168.0.0/16"] # Will be JSON-encoded by upstream module - type = "String" - value = "https://api.example.nhs.uk" + description = "Allowed source IP ranges for VPN/bastion access" +} +``` + +### 5. Parameter with external value management (Secrets Rotation) + +When a rotation Lambda manages the secret value outside Terraform: + +```hcl +module "rotated_api_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + stack = "integration" + name = "rotated-secret" + + type = "SecureString" + key_id = module.kms.key_arn + + value = var.initial_secret_value + + # Prevent Terraform from overwriting rotated values on apply ignore_value_changes = true + + description = "API secret that is auto-rotated every 30 days by Lambda" +} +``` + +### 6. Path-style hierarchical parameter + +Store related configuration under a hierarchical path: + +```hcl +module "app_database_config" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + stack = "application" + name = "database/connection" # Results in: /////database/connection + + type = "String" + value = jsonencode({ + pool_size = 10 + timeout = 30 + retry_count = 3 + }) + + description = "Application database connection pool configuration" +} +``` + +### 7. Write-only parameter (never stored in Terraform state) + +For production secrets where state exposure is a compliance risk: + +```hcl +module "production_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + stack = "production" + name = "api-secret" + + type = "SecureString" + key_id = module.kms.key_arn + + # Use value_wo_version instead of value — secret is never stored in Terraform state + # Increment value_wo_version whenever the secret value changes to force Terraform to update + value_wo_version = timestamp() # or use a counter: var.secret_version_counter + + description = "Production API secret (never stored in state)" } ``` ## Conventions -* The parameter name comes from context labels. If `name` contains `/`, the module ensures the final parameter name is fully qualified (starts with `/`). -* `type` must be one of `String`, `StringList`, or `SecureString`. -* When using `SecureString`, you must provide `key_id`. -* `values` are JSON-encoded by the upstream module when storing list-style values. -* Set `ignore_value_changes = true` when values are managed outside Terraform and should not be reconciled on every apply. +- **Naming:** Parameter names are derived from context labels (`////`). All names are fully qualified starting with `/`. Override with `parameter_name` if custom naming is required. +- **Type selection:** + - `String` — for plaintext configuration (URLs, counts, feature flags, etc.) + - `StringList` — for comma-separated lists; the module JSON-encodes the values for you + - `SecureString` — for sensitive values (passwords, API keys, tokens); **requires `key_id`** +- **SecureString encryption (MANDATORY):** When `type = "SecureString"`, you **must** provide a `key_id` (KMS key ARN or ID). This is enforced at `terraform plan` time and prevents unencrypted secrets. +- **Sensitive values:** Use Terraform variables with `sensitive = true` when passing secret values: + + ```hcl + variable "db_password" { + type = string + sensitive = true + } + ``` + +- **External value management:** Set `ignore_value_changes = true` when values are rotated by Lambda or other automation. This prevents Terraform from reverting externally-updated values on `apply`. +- **Write-only secrets:** Use `value_wo_version` (instead of `value`) for compliance-sensitive deployments where secrets must never be stored in Terraform state. Increment the trigger value to force updates. ## What this module does NOT do -* Create or manage a KMS key. Provide an existing key ARN/ID via `key_id` for `SecureString` parameters. -* Manage parameter policies (for example expiration, no-change notifications, or advanced policy lifecycle controls). -* Resolve secrets from external systems. The caller must provide `value`, `values`, or `value_wo_version`. -* Manage IAM permissions for reading/writing parameters. Attach IAM policies in consumer stacks/modules. +- **Create KMS keys:** Provide an existing key ARN/ID via `key_id` for `SecureString` parameters; create keys separately using the `kms` module. +- **Manage parameter policies:** IAM policy attachment, resource-based policies, or access controls are caller responsibility. +- **Rotate secrets automatically:** Configure rotation using `aws_ssm_parameter` + Lambda directly in consumer stacks if needed. Set `ignore_value_changes = true` to prevent Terraform conflicts. +- **Resolve secrets from external systems:** The caller must explicitly provide `value`, `values`, or `value_wo_version` — no automatic fetching from Vault, Secrets Manager, or other sources. +- **Create IAM permissions:** Attach policies to roles/users who need to read/write parameters; this module creates the parameter only. +- **Share parameters across AWS accounts:** SSM parameters are account-scoped; use AWS Secrets Manager for cross-account sharing or cross-stack references. + +## Validation + +The following constraints are enforced at `plan` time and prevent invalid configurations before any resources are created: + +### Variable-Level Validation + +- **Type validation:** `type` must be `String`, `StringList`, or `SecureString` (enforced in variables.tf) +- **KMS key requirement:** When `type = "SecureString"`, `key_id` is mandatory (enforced in variables.tf) +- **Parameter name format:** `parameter_name` must start with `/` if overridden (enforced in variables.tf) + +### Cross-Variable Validation (enforced in validations.tf) + +- **Value mutual exclusivity:** Cannot set both `value` and `values` at the same time — specify only one source +- **Write-only scope:** `value_wo_version` (version trigger) is only valid when `type = "SecureString"` +- **Required value source:** At least one of `value`, `values`, or `value_wo_version` must be provided (cannot omit all three) +- **At least one value source**: One of `value`, `values`, or `value_wo_version` must be provided. Using all three is invalid. @@ -91,7 +240,9 @@ module "shared_api_endpoint" { ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -103,7 +254,9 @@ No providers. ## Resources -No resources. +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs @@ -148,9 +301,9 @@ No resources. | [tier](#input\_tier) | Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [type](#input\_type) | Type of the parameter. Valid types are `String`, `StringList` and `SecureString` | `string` | n/a | yes | -| [value](#input\_value) | Value of the parameter | `string` | `null` | no | +| [value](#input\_value) | Value of the parameter. Can contain secrets such as database passwords or API keys. | `string` | `null` | no | | [value\_wo\_version](#input\_value\_wo\_version) | Value of the parameter. This value is always marked as sensitive in the Terraform plan output, regardless of type. Additionally, write-only values are never stored to state. `value_wo_version` can be used to trigger an update and is required with this argument | `number` | `null` | no | -| [values](#input\_values) | List of values of the parameter (will be jsonencoded to store as string natively in SSM) | `list(string)` | `[]` | no | +| [values](#input\_values) | List of values of the parameter (will be jsonencoded to store as string natively in SSM). Can contain secrets. | `list(string)` | `[]` | 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/ssm-parameter/main.tf b/infrastructure/modules/ssm-parameter/main.tf index d5ab402e..06fdc738 100644 --- a/infrastructure/modules/ssm-parameter/main.tf +++ b/infrastructure/modules/ssm-parameter/main.tf @@ -1,8 +1,15 @@ ################################################################ -# SSM Parameter with context-derived naming and tagging +# SSM Parameter # -# This module uses the label/tags module to generate hierarchical -# parameter names with forward slashes (e.g., /service/project/env/stack/name) +# Thin NHS wrapper around the community ssm-parameter module +# that enforces the screening platform's baseline controls: +# +# * Naming: derived from context labels via module.ssm_param_label.id +# * Tagging: all NHS-required tags applied via module.ssm_param_label.tags +# * SecureString: KMS key_id is mandatory (enforced via validation) +# * Enabled flag: create = module.ssm_param_label.enabled +# +# Cross-variable input constraints are enforced in validations.tf. ################################################################ module "ssm_param_label" { diff --git a/infrastructure/modules/ssm-parameter/validations.tf b/infrastructure/modules/ssm-parameter/validations.tf new file mode 100644 index 00000000..c9e4667c --- /dev/null +++ b/infrastructure/modules/ssm-parameter/validations.tf @@ -0,0 +1,31 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * value and values are mutually exclusive — specify only one +# * value_wo_version (write-only version trigger) only applies +# to SecureString parameters +# * At least one value source must be provided (value, values, +# or value_wo_version) +################################################################ + +resource "terraform_data" "validations" { + count = module.ssm_param_label.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = !(var.value != null && length(var.values) > 0) + error_message = "value and values are mutually exclusive; specify only one." + } + precondition { + condition = var.type != "SecureString" || var.value_wo_version == null || (var.value != null || var.value_wo_version != null) + error_message = "value_wo_version is only valid when type is \"SecureString\"." + } + precondition { + condition = var.value != null || length(var.values) > 0 || var.value_wo_version != null + error_message = "At least one of value, values, or value_wo_version must be provided." + } + } +} diff --git a/infrastructure/modules/ssm-parameter/variables.tf b/infrastructure/modules/ssm-parameter/variables.tf index 42d330ff..97d2fc69 100644 --- a/infrastructure/modules/ssm-parameter/variables.tf +++ b/infrastructure/modules/ssm-parameter/variables.tf @@ -67,9 +67,10 @@ variable "type" { } variable "value" { - description = "Value of the parameter" + description = "Value of the parameter. Can contain secrets such as database passwords or API keys." type = string default = null + sensitive = true } variable "value_wo_version" { @@ -79,7 +80,8 @@ variable "value_wo_version" { } variable "values" { - description = "List of values of the parameter (will be jsonencoded to store as string natively in SSM)" + description = "List of values of the parameter (will be jsonencoded to store as string natively in SSM). Can contain secrets." type = list(string) default = [] + sensitive = true } From 13c5807f095bb0fa0d43b61f65a5d9be170bc3f2 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Wed, 22 Jul 2026 09:44:44 +0100 Subject: [PATCH 12/26] fix: update log group and secret name logic to support path-style naming with delimiter --- .../modules/cloudwatch-logs/README.md | 56 ++++++++++++++++--- .../modules/cloudwatch-logs/locals.tf | 3 +- .../modules/secrets-manager/README.md | 56 ++++++++++++++++++- .../modules/secrets-manager/locals.tf | 3 +- .../modules/ssm-parameter/README.md | 50 ++++++++++++++++- .../modules/ssm-parameter/locals.tf | 3 +- 6 files changed, 157 insertions(+), 14 deletions(-) diff --git a/infrastructure/modules/cloudwatch-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md index ee2838a6..fcdc17ca 100644 --- a/infrastructure/modules/cloudwatch-logs/README.md +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -7,7 +7,7 @@ NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https:// | Control | How it is enforced | | --- | --- | | Log group | Always created when `module.this.enabled = true`; logging is mandatory by design | -| Naming convention | Log group name follows NHS pattern: `/////` (derived from context); forward slashes preserved | +| Naming convention | Log group name derived from context; when `delimiter = "/"`, names are path-style with leading `/` (e.g., `/////`); forward slashes preserved | | Encryption at rest | Always enabled; AWS-managed encryption by default, or customer-managed KMS when `kms_key_id` is provided | | Retention policy | Configurable; defaults to 30 days; only AWS-approved retention values accepted | | Stream management | Optional; streams only created when `stream_names` is non-empty; use `for_each` for stable iteration | @@ -22,7 +22,7 @@ NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https:// ```hcl module "app_logs" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" context = module.this.context @@ -42,7 +42,7 @@ output "ecs_log_group" { ```hcl module "audit_logs" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" context = module.this.context @@ -55,7 +55,7 @@ module "audit_logs" { ```hcl module "worker_logs" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" context = module.this.context @@ -71,7 +71,7 @@ module "worker_logs" { ```hcl module "archive_logs" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=v1.0.0" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" context = module.this.context @@ -81,13 +81,53 @@ module "archive_logs" { } ``` +### Path-style naming with `delimiter = "/"` + +When you set `delimiter = "/"`, log group names include a leading `/` and use `/` throughout: + +```hcl +module "path_style_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" + + context = module.this.context + delimiter = "/" # Path-style naming + stack = "api" + name = "events" + + retention_in_days = 30 +} + +# Results in log group name: /bcss/screening/prod/api/events +# (assuming context: service=bcss, project=screening, environment=prod) +``` + +### Standard naming (default delimiter: `-`) + +Without explicit `delimiter`, log group names use the default hyphen separator: + +```hcl +module "standard_logs" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch-logs?ref=" + + context = module.this.context + # delimiter not set — uses default "-" + stack = "api" + name = "events" + + retention_in_days = 30 +} + +# Results in log group name: bcss-screening-prod-api-events +# (no leading /) +``` + ## Conventions ### Naming -- **Log group name**: Defaults to `/////` derived from `module.this.context`. - - Forward slashes are preserved (e.g., `/bcss/website/prod/api/events`). - - Override with `log_group_name` variable if custom naming is needed; must start with `/`. +- **Log group name**: Derived from context labels (`////`). When `delimiter = "/"`, names are path-style with a leading `/` (e.g., `/bcss/website/prod/api/events`). When using the default delimiter, names are hyphen-separated without a leading `/` (e.g., `bcss-website-prod-api-events`). + - Forward slashes are preserved in context-derived names when `delimiter = "/"`. + - Override with `log_group_name` variable if custom naming is needed. - **Stream names**: Each stream name in `stream_names` becomes a separate CloudWatch log stream within the group. - Valid characters: alphanumeric, `.`, `-`, `_`, `/`, `#`. - Example: `stream_names = ["application", "error", "audit"]` creates 3 streams. diff --git a/infrastructure/modules/cloudwatch-logs/locals.tf b/infrastructure/modules/cloudwatch-logs/locals.tf index ecebd212..6f798f1b 100644 --- a/infrastructure/modules/cloudwatch-logs/locals.tf +++ b/infrastructure/modules/cloudwatch-logs/locals.tf @@ -1,4 +1,5 @@ locals { # Log group name: use custom name if provided, otherwise derive from context - log_group_name = var.log_group_name != null ? var.log_group_name : module.log_group_label.id + # When delimiter is "/", prepend "/" to ensure path-style log group names + log_group_name = var.log_group_name != null ? var.log_group_name : (module.log_group_label.delimiter == "/" ? format("/%s", module.log_group_label.id) : module.log_group_label.id) } diff --git a/infrastructure/modules/secrets-manager/README.md b/infrastructure/modules/secrets-manager/README.md index 04858c29..7646e7ba 100644 --- a/infrastructure/modules/secrets-manager/README.md +++ b/infrastructure/modules/secrets-manager/README.md @@ -103,6 +103,60 @@ module "rotated_password" { } ``` +## Naming Effect: Standard vs Path-Style + +The `delimiter` setting determines whether secret names include leading `/` and use `/` as separators. + +### Path-style naming with `delimiter = "/"` + +When you set `delimiter = "/"`, secret names include a leading `/` and use `/` throughout: + +```hcl +module "path_style_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" + + context = module.this.context + delimiter = "/" # Path-style naming + stack = "database" + name = "credentials" + + description = "Database credentials" + kms_key_id = module.kms.key_arn + secret_string = jsonencode({ + username = "admin" + password = var.db_password + }) +} + +# Results in secret name: /bcss/screening/prod/database/credentials +# (assuming context: service=bcss, project=screening, environment=prod) +``` + +### Standard naming (default delimiter: `-`) + +Without explicit `delimiter`, secret names use the default hyphen separator: + +```hcl +module "standard_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" + + context = module.this.context + # delimiter not set — uses default "-" + stack = "database" + name = "credentials" + + description = "Database credentials" + kms_key_id = module.kms.key_arn + secret_string = jsonencode({ + username = "admin" + password = var.db_password + }) +} + +# Results in secret name: bcss-screening-prod-database-credentials +# (no leading /) +``` + ## Conventions - `block_public_policy` is always `true` and cannot be overridden — public access to secrets is never permitted. @@ -115,7 +169,7 @@ module "rotated_password" { - `random_password_override_special` overrides the default special character set used during password generation. - `rotate_immediately` controls whether rotation fires immediately (`true`) or waits for the next scheduled window (`false`); only relevant when `enable_rotation = true`. - `create_policy` defaults to `false`; set to `true` and provide `policy_statements` to attach a resource-based policy. -- Secret names are derived from `module.secret_label.id` (hierarchical, forward-slash safe) for consistency with other screening modules. +- **Secret naming**: Derived from context labels. When `delimiter = "/"`, names are path-style with a leading `/` (e.g., `/bcss/screening/prod/db/credentials`); when using the default delimiter, names are hyphen-separated without a leading `/` (e.g., `bcss-screening-prod-db-credentials`). Override with `secret_name` if custom naming is required. ## What this module does NOT do diff --git a/infrastructure/modules/secrets-manager/locals.tf b/infrastructure/modules/secrets-manager/locals.tf index a84ff4d8..615b0288 100644 --- a/infrastructure/modules/secrets-manager/locals.tf +++ b/infrastructure/modules/secrets-manager/locals.tf @@ -1,5 +1,6 @@ locals { # Secret name: use custom override if provided, otherwise derive from context # labels via module.secret_label.id (e.g. "bcss-screening-test-db-credentials") - secret_name = var.secret_name != null ? var.secret_name : module.secret_label.id + # When delimiter is "/", prepend "/" to ensure path-style secrets names + secret_name = var.secret_name != null ? var.secret_name : (module.secret_label.delimiter == "/" ? format("/%s", module.secret_label.id) : module.secret_label.id) } diff --git a/infrastructure/modules/ssm-parameter/README.md b/infrastructure/modules/ssm-parameter/README.md index db4e0353..6b25b394 100644 --- a/infrastructure/modules/ssm-parameter/README.md +++ b/infrastructure/modules/ssm-parameter/README.md @@ -8,7 +8,7 @@ module that enforces the screening platform's baseline controls for parameter na | Control | How it is enforced | Impact | | --- | --- | --- | -| Naming consistency | Parameter name is derived from context labels; path-style names always start with `/` | Ensures hierarchical organisation across teams | +| Naming consistency | Parameter name is derived from context labels; when `delimiter = "/"`, names are path-style and start with `/` | Ensures hierarchical organisation across teams | | Tagging consistency | All tags sourced from `module.this.tags` (NHS-standard set) | Ensures billing, compliance, and governance controls | | KMS encryption for secrets | `key_id` is **mandatory** when `type = "SecureString"` | No unencrypted secrets in SSM; prevents accidental exposure | | Sensitive value protection | `value` and `values` marked `sensitive = true` in Terraform | Prevents secrets from appearing in logs/state diffs | @@ -182,9 +182,55 @@ module "production_secret" { } ``` +## Naming Effect: Standard vs Path-Style + +The `delimiter` setting determines whether parameter names include leading `/` and use `/` as separators. + +### Path-style naming with `delimiter = "/"` + +When you set `delimiter = "/"`, the parameter name includes a leading `/` and uses `/` throughout: + +```hcl +module "path_style_parameter" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + delimiter = "/" # Path-style naming + stack = "api" + name = "config" + + type = "String" + value = "prod-value" +} + +# Results in parameter name: /bcss/screening/prod/api/config +# (assuming context: service=bcss, project=screening, environment=prod) +``` + +### Standard naming (default delimiter: `-`) + +Without explicit `delimiter`, names use the default hyphen separator and no leading `/`: + +```hcl +module "standard_parameter" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + context = module.this.context + # delimiter not set — uses default "-" + stack = "api" + name = "config" + + type = "String" + value = "prod-value" +} + +# Results in parameter name: bcss-screening-prod-api-config +# (no leading /) +``` + ## Conventions -- **Naming:** Parameter names are derived from context labels (`////`). All names are fully qualified starting with `/`. Override with `parameter_name` if custom naming is required. +- **Naming:** Parameter names are derived from context labels. When `delimiter = "/"`, names are path-style with a leading `/` (e.g., `/bcss/prod/app/config`). When using the default delimiter, names are hyphen-separated without a leading `/` (e.g., `bcss-prod-app-config`). Override with `parameter_name` if custom naming is required. - **Type selection:** - `String` — for plaintext configuration (URLs, counts, feature flags, etc.) - `StringList` — for comma-separated lists; the module JSON-encodes the values for you diff --git a/infrastructure/modules/ssm-parameter/locals.tf b/infrastructure/modules/ssm-parameter/locals.tf index d32a3994..edec398b 100644 --- a/infrastructure/modules/ssm-parameter/locals.tf +++ b/infrastructure/modules/ssm-parameter/locals.tf @@ -1,4 +1,5 @@ locals { # Parameter name: use custom override if provided, otherwise derive from context - parameter_name = var.parameter_name != null ? var.parameter_name : module.ssm_param_label.id + # When delimiter is "/", prepend "/" to ensure path-style parameter names (AWS SSM standard) + parameter_name = var.parameter_name != null ? var.parameter_name : (module.ssm_param_label.delimiter == "/" ? format("/%s", module.ssm_param_label.id) : module.ssm_param_label.id) } From 79662433251f04075510e18a3827dc565ed7833e Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Wed, 22 Jul 2026 11:16:39 +0100 Subject: [PATCH 13/26] feat: Introduce VPC Endpoint Module - Added a new VPC Endpoint module to create Interface and Gateway VPC endpoints with support for custom configurations. - Implemented context and variable definitions for tagging and endpoint configurations. - Created local values to intelligently merge endpoint configurations with module defaults. - Added validations to enforce security and configuration requirements across endpoints. - Updated main module to utilize the new VPC Endpoint module, removing legacy endpoint configurations. - Updated available modules documentation to include the new VPC Endpoint module. --- .github/dependabot.yaml | 1 + README.md | 5 +- .../modules/vpc-endpoint/.terraform.lock.hcl | 30 + infrastructure/modules/vpc-endpoint/README.md | 671 ++++++++++++++++++ .../modules/vpc-endpoint/context.tf | 377 ++++++++++ infrastructure/modules/vpc-endpoint/locals.tf | 36 + infrastructure/modules/vpc-endpoint/main.tf | 29 + .../modules/vpc-endpoint/outputs.tf | 17 + .../modules/vpc-endpoint/validations.tf | 84 +++ .../modules/vpc-endpoint/variables.tf | 93 +++ .../modules/vpc-endpoint/versions.tf | 10 + infrastructure/modules/vpc/main.tf | 36 - infrastructure/modules/vpc/outputs.tf | 9 - infrastructure/modules/vpc/variables.tf | 33 - .../config/generate-available-modules.yaml | 8 +- 15 files changed, 1357 insertions(+), 82 deletions(-) create mode 100644 infrastructure/modules/vpc-endpoint/.terraform.lock.hcl create mode 100644 infrastructure/modules/vpc-endpoint/README.md create mode 100644 infrastructure/modules/vpc-endpoint/context.tf create mode 100644 infrastructure/modules/vpc-endpoint/locals.tf create mode 100644 infrastructure/modules/vpc-endpoint/main.tf create mode 100644 infrastructure/modules/vpc-endpoint/outputs.tf create mode 100644 infrastructure/modules/vpc-endpoint/validations.tf create mode 100644 infrastructure/modules/vpc-endpoint/variables.tf create mode 100644 infrastructure/modules/vpc-endpoint/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 26ae5fba..c831984e 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -78,6 +78,7 @@ updates: - "infrastructure/modules/sqs" - "infrastructure/modules/ssm-parameter" - "infrastructure/modules/tags" + - "infrastructure/modules/vpc-endpoint" - "infrastructure/modules/vpc" - "infrastructure/modules/vpce" - "infrastructure/modules/vpces" diff --git a/README.md b/README.md index 29670309..dc42d14a 100644 --- a/README.md +++ b/README.md @@ -346,8 +346,9 @@ Rules: | `ssm-parameter` | — | — | | `tags` | — | Foundation: naming and tagging context module | | `vpc` | terraform-aws-modules/vpc/aws | VPC with subnets, routing, and gateways | -| `vpce` | — | VPC endpoint (single service) | -| `vpces` | — | VPC endpoints (multiple services) | +| `vpc-endpoint` | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | VPC endpoints (Interface and Gateway) with policies | +| `vpce` | — | VPC endpoint (single service) (legacy) | +| `vpces` | — | VPC endpoints (multiple services) (legacy) | | `waf` | — | WAF web ACL with rules | diff --git a/infrastructure/modules/vpc-endpoint/.terraform.lock.hcl b/infrastructure/modules/vpc-endpoint/.terraform.lock.hcl new file mode 100644 index 00000000..caf1d989 --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/.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.55.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "h1:3OSTaIx7d6T+N6gwNtw0gR+IwExbHqb0y4v529Vey/4=", + "h1:99+MYIg/y3gmsZkhAcffwOpMat+liRJ8b+eyCIax6hk=", + "h1:QIQoZv637BE40vg7/0gf/5gQbY/ZTngSuJWDl7fJr2s=", + "h1:tBAaeIprhWQmlsq/CoHLXWOLZZYRdCVA1wbNv2co55g=", + "h1:yM6149NHHl8NWU/OCIV2/x6tLrNgDTRKgR8cVrbk9BU=", + "zh:1161fb2d032ad982587b2662a5229e5d06598c5b7fc5c86b2ad64d49225047cd", + "zh:1f412b09bbece216da0ba08106f3bbb42d8c8971c02d032ab518629915086966", + "zh:2c8b789450bb67181b5f0546714bf6336ba21183c307e001fe848c22dac1f8a6", + "zh:31eec91f896743bab641c06930fe0c277143f17dd25b2510991c08e013c8da67", + "zh:4419d3e906f1ca9c99703b2c4c5082f58aaeb8b8b82e2657a187a6bdf42d8881", + "zh:58e9a7e0581e8cd5f35eb2ce308b2d572073c112facdd0a60aee032146b146b5", + "zh:72fdb02a0cb6351626df460c047d1471f26dad781160cc95abd84f8849daf950", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:aa527913348c33969d80527d424917876657741493f294e160f1191dbc7c1d45", + "zh:b66e5abd756064f55ff06e1ef83eb8d0b7ea6d96625cfcd24408df5472d5f899", + "zh:ba9ab4ba151ac69a854407e27d3a12a2eb260fc4205bbb5c25618e6c8ce69568", + "zh:e1bf63e8a6b9790836f74848b458cdf679a7841e66a25b4b9f6034efc9310b34", + "zh:ec4adcec426f7181faa5de976cedbd35e15d7d1ac2914bbd9cab9f4f8b018b7f", + "zh:f17b485ae74bb4272d2bd680e7d47b0cfd073d192a4f10c8bdfd9d9f25c990a1", + "zh:f6650c2d0d3e614c3ccd623bb027a52bce1f5285c4f1824a26f265eb7529a45a", + "zh:f7383732f8704099db2166a3516e0a803bf46f42c30cd20a0471b55658ae6e51", + ] +} diff --git a/infrastructure/modules/vpc-endpoint/README.md b/infrastructure/modules/vpc-endpoint/README.md new file mode 100644 index 00000000..f494a0aa --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/README.md @@ -0,0 +1,671 @@ +# VPC Endpoints + +Thin wrapper around the community [`terraform-aws-modules/vpc/aws//modules/vpc-endpoints`](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest/submodules/vpc-endpoints) submodule for creating Interface and Gateway VPC endpoints with optional per-endpoint policies, default security group assignment, and subnet customization. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Caller-controlled security | Module does NOT create security groups; caller must create and pass them | +| Default security groups | Single `security_group_id` can default all Interface endpoints; per-endpoint override via `security_group_ids` | +| Endpoint policies | Module passes through optional policies for both Gateway (S3/DynamoDB) and Interface (SNS, SQS, etc.) endpoints; caller responsible for restrictive policies | +| Subnet isolation | Default placement in intra subnets (no internet route); can override per-endpoint | +| Consistent tagging | All resources tagged via `var.tags`; no additional tagging logic | + +## What this module provides + +- **Interface endpoint support** — Creates endpoints in specified subnets (default: all subnets in `var.subnet_ids`) +- **Gateway endpoint support** — Creates endpoints with route table associations (S3, DynamoDB) +- **Default security group** — Single `var.security_group_id` applies to all Interface endpoints unless overridden per-endpoint +- **Default subnets** — Single `var.subnet_ids` applies to all endpoints unless overridden per-endpoint +- **Per-endpoint overrides** — `security_group_ids`, `subnet_ids`, and `policy` customizable per endpoint +- **Per-endpoint policies** — Optional endpoint policies for both Gateway and Interface endpoints (recommended for security) +- **Consistent tagging** — All resources tagged via `var.tags` + +## Usage + +### Example 1: Minimal Interface endpoints with default security group + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.vpc_endpoints.id + + endpoints = { + ecr_api = { + service = "ecr.api" + service_type = "Interface" + # Inherits security_group_id from var.security_group_id + } + + ecr_dkr = { + service = "ecr.api" + service_type = "Interface" + # Inherits security_group_id from var.security_group_id + } + } + + tags = var.tags +} +``` + +### Example 2: Gateway endpoint (S3) + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + + endpoints = { + s3 = { + service = "s3" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + # Optional: restrictive policy to limit access + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject", "s3:ListBucket"] + Resource = [ + "arn:aws:s3:::my-bucket", + "arn:aws:s3:::my-bucket/*" + ] + }] + }) + } + } + + tags = var.tags +} +``` + +### Example 3: Gateway endpoint (DynamoDB) + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + + endpoints = { + dynamodb = { + service = "dynamodb" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + # Restrict to specific table and actions + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"] + Resource = "arn:aws:dynamodb:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:table/my-table" + }] + }) + } + } + + tags = var.tags +} +``` + +### Example 4: Interface endpoint (basic) + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.interface_endpoints.id + + endpoints = { + sns = { + service = "sns" + service_type = "Interface" + # Inherits all intra subnets (default) + # Inherits security_group_id from var.security_group_id + } + + sqs = { + service = "sqs" + service_type = "Interface" + # Inherits all intra subnets (default) + # Inherits security_group_id from var.security_group_id + } + } + + tags = var.tags +} +``` + +### Example 5: Interface endpoint with user-defined IP address (static ENI placement) + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.interface_endpoints.id + + endpoints = { + ecr_api = { + service = "ecr.api" + service_type = "Interface" + # Fixed IPs in specific subnet (single AZ, lower cost) + subnet_ids = [module.vpc.intra_subnet_ids[0]] + private_dns_enabled = true + # Optional: specify exact private IPs via network_interface_ids if needed + } + } + + tags = var.tags +} +``` + +### Example 6: PrivateLink to RDS with policy and security group + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + + endpoints = { + rds = { + service = "rds" + service_type = "Interface" + security_group_ids = [aws_security_group.rds_endpoint.id] + private_dns_enabled = true + # Optional: policy to restrict RDS endpoint access + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["rds:DescribeDBInstances", "rds:DescribeDBClusters"] + Resource = "arn:aws:rds:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:db:*" + }] + }) + } + } + + tags = var.tags +} + +# Create RDS-specific security group (separate from generic endpoints) +resource "aws_security_group" "rds_endpoint" { + name_prefix = "rds-endpoint-" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [aws_security_group.application_tier.id] + } +} +``` + +### Example 7: PrivateLink to EC2 with policy and security group + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + + endpoints = { + ec2 = { + service = "ec2" + service_type = "Interface" + security_group_ids = [aws_security_group.ec2_endpoint.id] + # Optional: policy to restrict EC2 API access + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["ec2:DescribeInstances", "ec2:DescribeSecurityGroups"] + Resource = "*" + }] + }) + } + } + + tags = var.tags +} + +resource "aws_security_group" "ec2_endpoint" { + name_prefix = "ec2-endpoint-" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.automation_tier.id] + } +} +``` + +### Example 8: PrivateLink to NLB with policy, security group, and private DNS + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + + endpoints = { + elasticloadbalancing = { + service = "elasticloadbalancing" + service_type = "Interface" + security_group_ids = [aws_security_group.nlb_endpoint.id] + private_dns_enabled = true + # Limit NLB API access to describe operations + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeLoadBalancerAttributes" + ] + Resource = "*" + }] + }) + } + } + + tags = var.tags +} + +resource "aws_security_group" "nlb_endpoint" { + name_prefix = "nlb-endpoint-" + vpc_id = module.vpc.vpc_id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.orchestration_tier.id] + } + + tags = { + Name = "nlb-endpoint-sg" + } +} +``` + +### Example 9: Complex: Multiple endpoint types with mixed defaults and overrides + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.generic_endpoints.id # Default for Interface endpoints + + endpoints = { + # Gateway endpoints (no security group) + s3 = { + service = "s3" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::my-bucket/*" + }] + }) + } + + dynamodb = { + service = "dynamodb" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + } + + # Interface endpoints - uses default security_group_id + ecr_api = { + service = "ecr.api" + service_type = "Interface" + # Inherits var.security_group_id and var.subnet_ids + } + + ecr_dkr = { + service = "ecr.dkr" + service_type = "Interface" + # Inherits var.security_group_id and var.subnet_ids + } + + # Interface endpoint - overrides security group + secretsmanager = { + service = "secretsmanager" + service_type = "Interface" + security_group_ids = [aws_security_group.secrets_endpoint.id] + # Uses default subnet_ids + } + + # Interface endpoint - overrides both security group and subnets (single AZ) + ssm = { + service = "ssm" + service_type = "Interface" + security_group_ids = [aws_security_group.ssm_endpoint.id] + subnet_ids = [module.vpc.intra_subnet_ids[0]] # Cost optimization + } + + # Interface endpoint - overrides everything including private DNS + rds = { + service = "rds" + service_type = "Interface" + security_group_ids = [aws_security_group.rds_endpoint.id] + subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2) # Two AZs for HA + private_dns_enabled = true + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = data.aws_caller_identity.current.arn } + Action = "rds-db:connect" + Resource = "*" + }] + }) + } + } + + tags = var.tags +} +``` + +## Conventions + +### Security group and subnet handling + +**Security groups** (Interface endpoints only): + +| Scenario | Behavior | +| --- | --- | +| Endpoint specifies `security_group_ids` | Uses those (takes precedence) | +| Endpoint does NOT specify `security_group_ids` + `var.security_group_id` set | Uses `var.security_group_id` | +| Endpoint does NOT specify `security_group_ids` + `var.security_group_id = null` | **Validation error** for Interface endpoints | +| Gateway endpoint | Ignored; does not support security groups | + +**Subnets** (all endpoint types): + +| Scenario | Behavior | +| --- | --- | +| Endpoint specifies `subnet_ids` | Uses those (takes precedence) | +| Endpoint does NOT specify `subnet_ids` + `var.subnet_ids` set | Uses `var.subnet_ids` for all endpoints | +| Gateway endpoint with route table placement | `subnet_ids` ignored; uses `route_table_ids` instead | + +### Endpoint types and required attributes + +| Type | Required attributes | Optional attributes | +| --- | --- | --- | +| Interface | `service`, and either per-endpoint `security_group_ids` OR `var.security_group_id` | `subnet_ids`, `policy`, `private_dns_enabled`, `tags` | +| Gateway | `service`, `route_table_ids` | `policy`, `tags` | + +### Naming and variable precedence + +This module is a pass-through wrapper with no complex naming logic. The caller is responsible for: + +- **Endpoint logical names**: Use clear, descriptive keys in the `endpoints` map (e.g., `ecr_api`, `s3`, `secretsmanager`) +- **Service names**: AWS service names (e.g., `s3`, `ecr.api`, `ecr.dkr`) are passed directly to the upstream module +- **Subnet placement**: Default to `intra_subnet_ids` (no internet route); override per-endpoint via `subnet_ids` for cost optimization + +### Caller responsibilities + +This module does **NOT** create: + +- **Security groups** — Create these at the stack level and pass `security_group_ids` per-endpoint or `var.security_group_id` as default +- **Subnets** — Caller must provide `var.subnet_ids` (default placement) and per-endpoint `subnet_ids` overrides +- **Endpoint policies** — Caller must supply if access restriction is needed (default: open to all principals) +- **Route tables** — For Gateway endpoints, pass existing route table IDs +- **IAM policies** — Caller owns any IAM permissions to consume the endpoints + +### AWS service names by endpoint type + +**Interface endpoints** (most AWS services): + +- Container services: `ecr.api`, `ecr.dkr` +- Secrets: `secretsmanager`, `ssm` +- SNS/SQS: `sns`, `sqs` +- CloudWatch: `logs`, `monitoring` +- Many others; see [AWS documentation](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-service.html) + +**Gateway endpoints** (only): + +- `s3` +- `dynamodb` + +## Security Considerations + +### Endpoint policies (Gateway endpoints only: S3, DynamoDB) + +**Gateway endpoints** (S3, DynamoDB) support endpoint policies to restrict access. **By default, VPC endpoints allow ALL principals and actions.** This is a significant security risk in most deployments. Always supply restrictive `policy` attributes for Gateway endpoints: + +```hcl +endpoints = { + s3 = { + service = "s3" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::my-bucket/*" + Condition = { + StringEquals = { + "aws:PrincipalOrgID" = var.organization_id + } + } + }] + }) + } +} +``` + +Without policies, any IAM principal in your account (or federated users) can access any S3 bucket or service through the endpoint. + +**Interface endpoints do NOT support policies.** Instead, they use security groups for access control (see below). + +### Security groups for Interface endpoints + +Interface endpoints require security groups to: + +- Restrict source IP ranges (e.g., only from specific security groups or subnets) +- Restrict ports and protocols + +**Best practice**: Create dedicated security groups per endpoint service rather than sharing a single group: + +```hcl +resource "aws_security_group" "ecr_endpoints" { + vpc_id = module.vpc.vpc_id + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.container_workloads.id] + } +} + +resource "aws_security_group" "secrets_endpoints" { + vpc_id = module.vpc.vpc_id + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.application_tier.id] + } +} +``` + +### Subnet placement + +- **Intra subnets (default)**: No internet route; isolated and secure for sensitive services +- **Private subnets**: Internet route through NAT; appropriate if endpoints serve public internet access needs +- **Per-endpoint override**: Use sparingly; document the reason for non-default subnet placement + +## What this module does NOT do + +- **Does NOT create security groups** — You must create security groups at the stack level and pass them +- **Does NOT create IAM policies** — IAM permissions to consume endpoints are the caller's responsibility +- **Does NOT enforce endpoint policies** — Endpoints default to open access; you must supply restrictive policies +- **Does NOT manage route table associations** — For Gateway endpoints, you pass existing route tables +- **Does NOT apply custom naming or labels** — Service/endpoint names are passed through as-is +- **Does NOT create DNS records** — Private DNS for Interface endpoints is configured via `private_dns_enabled`; caller owns Route53 setup if needed +- **Does NOT manage VPCs or subnets** — Caller must provide `vpc_id` and `subnet_ids` from the VPC module + +## Troubleshooting + +### "Duplicate security group" error + +Ensure you're creating security groups **outside** the endpoints module: + +```hcl +# ✓ Correct: security group created separately, passed to module +resource "aws_security_group" "vpc_endpoints" { + vpc_id = module.vpc.vpc_id +} + +module "vpc_endpoints" { + endpoints = { + ecr_api = { + service = "ecr.api" + security_group_ids = [aws_security_group.vpc_endpoints.id] + } + } +} +``` + +### "Endpoint not available" error + +For Gateway endpoints (S3, DynamoDB), ensure route tables include a route to the endpoint: + +```hcl +endpoints = { + s3 = { + service = "s3" + service_type = "Gateway" + route_table_ids = [module.vpc.private_route_table_ids[0]] + } +} +``` + +### "Permission denied" when using endpoints + +If you can't access services via endpoints, verify: + +1. **Endpoint policy** — If set, does it allow your IAM principal and action? +2. **Security group** — Are inbound rules permissive enough (port 443 for HTTPS)? +3. **DNS** — Is `private_dns_enabled = true` so DNS resolves to the endpoint? + +## References + +- [AWS VPC Endpoints documentation](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints.html) +- [Interface vs Gateway endpoints](https://docs.aws.amazon.com/vpc/latest/privatelink/vpce-interface.html) +- [Endpoint policy examples](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-access.html) +- [Upstream module documentation](https://github.com/terraform-aws-modules/terraform-aws-vpc/tree/master/modules/vpc-endpoints) + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [this](#module\_this) | ../tags | n/a | +| [vpc\_endpoints](#module\_vpc\_endpoints) | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | 6.6.1 | + +## 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 | +| [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 | +| [endpoints](#input\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints submodule.

**Interface endpoints** (default):
- Placed in intra subnets by default (can override via subnet\_ids)
- Require security\_group\_ids
- Support optional private\_dns\_enabled (default true)

**Gateway endpoints**:
- Must specify service\_type = "Gateway"
- Require route\_table\_ids
- Do NOT use security\_group\_ids or subnet\_ids

Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api") [REQUIRED]
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document (optional but recommended)
subnet\_ids - Override default intra subnets (optional; Interface only)
security\_group\_ids - Security group IDs (required for Interface endpoints)
private\_dns\_enabled - Enable private DNS (Interface only; default true)
route\_table\_ids - Route table IDs (required for Gateway endpoints)
tags - Per-endpoint tags (optional)

**Security consideration:** Endpoint policies restrict access. If not specified,
the endpoint allows all principals. Recommended to set restrictive policies. | `any` | `{}` | no | +| [endpoints\_not\_empty](#input\_endpoints\_not\_empty) | Internal validation: endpoints must not be empty | `any` | `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 | +| [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 | +| [security\_group\_id](#input\_security\_group\_id) | Default security group ID to associate with Interface endpoints. Per-endpoint security\_group\_ids override this default. Can be null if all Interface endpoints specify security\_group\_ids explicitly. | `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 | +| [subnet\_ids](#input\_subnet\_ids) | List of subnet IDs to use as default for Interface endpoints (recommended: intra subnets with no internet route). Can be overridden per-endpoint via subnet\_ids in the endpoints map. | `list(string)` | n/a | yes | +| [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) | The ID of the VPC where endpoints will be created. | `string` | n/a | yes | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [endpoints](#output\_endpoints) | Map of VPC endpoints created, keyed by logical name. Contains all endpoint attributes (id, arn, network\_interface\_ids, subnet\_ids, security\_groups, etc.). | +| [security\_groups](#output\_security\_groups) | Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create\_security\_group=false). | +| [validate\_endpoint\_policies](#output\_validate\_endpoint\_policies) | Informational output: Gateway endpoint policy coverage | +| [validate\_endpoints\_not\_empty](#output\_validate\_endpoints\_not\_empty) | Informational output: Confirms endpoints are specified | +| [validate\_gateway\_endpoint\_config](#output\_validate\_gateway\_endpoint\_config) | Validation output: Ensures Gateway endpoint configuration | +| [validate\_security\_group\_coverage](#output\_validate\_security\_group\_coverage) | Validation output: Ensures Interface endpoints have security group coverage | + + + diff --git a/infrastructure/modules/vpc-endpoint/context.tf b/infrastructure/modules/vpc-endpoint/context.tf new file mode 100644 index 00000000..b644ebfb --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/context.tf @@ -0,0 +1,377 @@ +# 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 +# +# +# 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" + + enabled = var.enabled + 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/vpc-endpoint/locals.tf b/infrastructure/modules/vpc-endpoint/locals.tf new file mode 100644 index 00000000..7ee07d0d --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/locals.tf @@ -0,0 +1,36 @@ +################################################################ +# Local values +# +# Merges per-endpoint configuration with module defaults (var.security_group_id). +# Interface endpoints use either their specified security_group_ids or the default. +################################################################ + +locals { + # Intelligently merge endpoints with module defaults (var.security_group_id, var.subnet_ids) + # Precedence: per-endpoint values > module defaults > upstream defaults + # This enables a caller to set defaults while allowing per-endpoint overrides. + endpoints_merged = { + for k, v in var.endpoints : + k => merge( + v, + # Merge security_group_ids: per-endpoint > default + # Only add security_group_ids if: + # 1. Endpoint doesn't already specify them AND + # 2. var.security_group_id is set AND + # 3. Endpoint is Interface type (or unspecified, defaulting to Interface) + try(v.security_group_ids, null) == null && + var.security_group_id != null && + try(v.service_type, "Interface") == "Interface" ? + { security_group_ids = [var.security_group_id] } : + {}, + # Merge subnet_ids: per-endpoint > default + # Only add subnet_ids if: + # 1. Endpoint doesn't already specify them AND + # 2. var.subnet_ids is set + try(v.subnet_ids, null) == null && + length(var.subnet_ids) > 0 ? + { subnet_ids = var.subnet_ids } : + {} + ) + } +} diff --git a/infrastructure/modules/vpc-endpoint/main.tf b/infrastructure/modules/vpc-endpoint/main.tf new file mode 100644 index 00000000..dc0b3d48 --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/main.tf @@ -0,0 +1,29 @@ +################################################################ +# VPC Endpoints Wrapper Module +# +# Creates Interface and Gateway VPC endpoints with support for: +# - Optional per-endpoint policies +# - Per-endpoint subnet overrides +# - Security group assignment (caller-managed) +# - Custom tags per endpoint +################################################################ + +module "vpc_endpoints" { + source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints" + version = "6.6.1" + + vpc_id = var.vpc_id + + # Default subnet placement: intra (no internet route, high isolation) + # Can be overridden per-endpoint via subnet_ids in endpoints map + subnet_ids = var.subnet_ids + + # Security groups are created/managed at the stack level (caller responsibility) + # Per-endpoint security_group_ids override the default var.security_group_id + create_security_group = false + + # Use merged endpoints map to apply default security_group_id where needed + endpoints = local.endpoints_merged + + tags = var.tags +} diff --git a/infrastructure/modules/vpc-endpoint/outputs.tf b/infrastructure/modules/vpc-endpoint/outputs.tf new file mode 100644 index 00000000..4d577720 --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/outputs.tf @@ -0,0 +1,17 @@ +################################################################ +# Outputs +# +# Expose all VPC endpoints created by the upstream module. +# For advanced use cases (e.g., extracting specific endpoint IDs), +# callers can access full endpoint attributes via this output. +################################################################ + +output "endpoints" { + description = "Map of VPC endpoints created, keyed by logical name. Contains all endpoint attributes (id, arn, network_interface_ids, subnet_ids, security_groups, etc.)." + value = module.vpc_endpoints.endpoints +} + +output "security_groups" { + description = "Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create_security_group=false)." + value = try(module.vpc_endpoints.security_groups, {}) +} diff --git a/infrastructure/modules/vpc-endpoint/validations.tf b/infrastructure/modules/vpc-endpoint/validations.tf new file mode 100644 index 00000000..b8a5f14b --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/validations.tf @@ -0,0 +1,84 @@ +################################################################ +# Validations (output preconditions) +# +# Enforce security and configuration requirements across endpoints. +################################################################ + +locals { + # Extract interface endpoints that don't have security_group_ids specified + interface_endpoints_without_sgs = [ + for k, v in var.endpoints : + k if try(v.service_type, "Interface") == "Interface" && try(v.security_group_ids, null) == null + ] + + # Check if any Gateway endpoints mistakenly have security_group_ids + gateway_endpoints_with_sgs = [ + for k, v in var.endpoints : + k if try(v.service_type, "Interface") == "Gateway" && try(v.security_group_ids, null) != null + ] + + # Gateway endpoints without policies (security best practice) + # Both Gateway and Interface endpoints support policies. + # Gateway endpoints (S3, DynamoDB) especially should have restrictive policies. + gateway_endpoints_without_policies = [ + for k, v in var.endpoints : + k if try(v.service_type, "Interface") == "Gateway" && try(v.policy, null) == null + ] + + # Gateway endpoints without route_table_ids + gateway_endpoints_without_rtb = [ + for k, v in var.endpoints : + k if try(v.service_type, "Interface") == "Gateway" && try(v.route_table_ids, null) == null + ] +} + +# Validation output: Ensures Interface endpoints have security group coverage +output "validate_security_group_coverage" { + value = null + + precondition { + condition = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null + error_message = "Interface endpoints (${join(", ", local.interface_endpoints_without_sgs)}) must have security_group_ids specified per-endpoint or use var.security_group_id as default." + } +} + +# Validation output: Ensures Gateway endpoint configuration +output "validate_gateway_endpoint_config" { + value = null + + precondition { + condition = length(local.gateway_endpoints_with_sgs) == 0 + error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_with_sgs)}) do not support security_group_ids. Remove this attribute or change service_type to Interface." + } + + precondition { + condition = length(local.gateway_endpoints_without_rtb) == 0 + error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_without_rtb)}) require route_table_ids to specify which route tables to associate with." + } +} + +# Ensure at least one endpoint is specified +variable "endpoints_not_empty" { + type = any + default = null + description = "Internal validation: endpoints must not be empty" + + validation { + condition = length(var.endpoints) > 0 + error_message = "At least one endpoint must be specified. If vpc-endpoint module is not needed, remove it from the configuration." + } +} + +# Note: Both Gateway and Interface endpoints support policies. +# Gateway endpoints (S3, DynamoDB) should use restrictive policies as a security best practice. +# Interface endpoints (SNS, SQS, Secrets Manager, etc.) can optionally use policies for fine-grained access control. +output "validate_endpoint_policies" { + value = length(local.gateway_endpoints_without_policies) == 0 ? "All Gateway endpoints have policies" : "Warning: Gateway endpoints (${join(", ", local.gateway_endpoints_without_policies)}) do not have restrictive policies. Recommended for security." + description = "Informational output: Gateway endpoint policy coverage" +} + +# Validation output: Indicates endpoints are specified +output "validate_endpoints_not_empty" { + value = "Endpoints defined" + description = "Informational output: Confirms endpoints are specified" +} diff --git a/infrastructure/modules/vpc-endpoint/variables.tf b/infrastructure/modules/vpc-endpoint/variables.tf new file mode 100644 index 00000000..8bcfb55e --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/variables.tf @@ -0,0 +1,93 @@ +################################################################ +# VPC Configuration +################################################################ + +variable "vpc_id" { + description = "The ID of the VPC where endpoints will be created." + type = string + + validation { + condition = can(regex("^vpc-[a-f0-9]{17}$", var.vpc_id)) + error_message = "vpc_id must be a valid AWS VPC ID (vpc-xxxxxxxxxxxxxxxx)." + } +} + +variable "subnet_ids" { + description = "List of subnet IDs to use as default for Interface endpoints (recommended: intra subnets with no internet route). Can be overridden per-endpoint via subnet_ids in the endpoints map." + type = list(string) + + validation { + condition = length(var.subnet_ids) > 0 + error_message = "subnet_ids must contain at least one subnet ID." + } +} + +variable "security_group_id" { + description = "Default security group ID to associate with Interface endpoints. Per-endpoint security_group_ids override this default. Can be null if all Interface endpoints specify security_group_ids explicitly." + type = string + default = null + + validation { + condition = var.security_group_id == null || can(regex("^sg-[a-f0-9]{17}$", var.security_group_id)) + error_message = "security_group_id must be a valid AWS security group ID (sg-xxxxxxxxxxxxxxxx) or null." + } +} + +################################################################ +# VPC Endpoints Configuration +################################################################ + +variable "endpoints" { + description = <<-EOT + Map of VPC endpoints to create. Each key is a logical name, + each value is passed through to the upstream vpc-endpoints submodule. + + **Interface endpoints** (default): + - Placed in intra subnets by default (can override via subnet_ids) + - Require security_group_ids + - Support optional private_dns_enabled (default true) + + **Gateway endpoints**: + - Must specify service_type = "Gateway" + - Require route_table_ids + - Do NOT use security_group_ids or subnet_ids + + Supported per-endpoint attributes: + service - AWS service name (e.g. "s3", "ecr.api") [REQUIRED] + service_type - "Interface" (default) or "Gateway" + policy - JSON endpoint policy document (optional but recommended) + subnet_ids - Override default intra subnets (optional; Interface only) + security_group_ids - Security group IDs (required for Interface endpoints) + private_dns_enabled - Enable private DNS (Interface only; default true) + route_table_ids - Route table IDs (required for Gateway endpoints) + tags - Per-endpoint tags (optional) + + **Security consideration:** Endpoint policies restrict access. If not specified, + the endpoint allows all principals. Recommended to set restrictive policies. + EOT + type = any + default = {} + + validation { + condition = alltrue([for k, v in var.endpoints : try(v.service, null) != null]) + error_message = "Each endpoint must specify 'service' (e.g. 's3', 'ecr.api')." + } + + validation { + condition = alltrue([ + for k, v in var.endpoints : + try(v.service_type, "Interface") != "Interface" || var.security_group_id != null || (try(v.security_group_ids, null) != null && length(try(v.security_group_ids, [])) > 0) + ]) + error_message = "Interface endpoints must have security groups via either var.security_group_id (default) or per-endpoint security_group_ids." + } + + validation { + condition = alltrue([ + for k, v in var.endpoints : + try(v.service_type, "Interface") == "Gateway" ? + try(v.route_table_ids, null) != null && length(try(v.route_table_ids, [])) > 0 : + true + ]) + error_message = "Gateway endpoints must specify route_table_ids with at least one route table." + } +} diff --git a/infrastructure/modules/vpc-endpoint/versions.tf b/infrastructure/modules/vpc-endpoint/versions.tf new file mode 100644 index 00000000..5db7ed74 --- /dev/null +++ b/infrastructure/modules/vpc-endpoint/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" # Platform baseline; terraform-aws-modules/vpc 6.6.1 supports this + } + } +} diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index d4dce53c..c2e4c6fd 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -238,39 +238,3 @@ module "flow_log" { tags = module.this.tags } -################################################################ -# VPC Endpoints -# -# Uses the standalone vpc-endpoints submodule from -# terraform-aws-modules/vpc/aws. -# -# Interface endpoints default to intra subnets (no internet -# route needed – they use AWS PrivateLink). Override per-endpoint -# with subnet_ids inside the endpoints map. -# -# Gateway endpoints (S3, DynamoDB) are attached to route tables -# specified per-endpoint via route_table_ids. -# -# Security groups are NOT managed here – callers should create -# them at the stack level using the security-group module and -# pass security_group_ids per-endpoint. -################################################################ - -module "vpc_endpoints" { - source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints" - version = "6.6.1" - - create = module.this.enabled && var.create_vpc_endpoints - - vpc_id = module.vpc.vpc_id - - # Default subnet placement: intra (no internet route) - subnet_ids = module.vpc.intra_subnets - - # Security groups are managed at the stack level - create_security_group = false - - endpoints = var.vpc_endpoints - - tags = module.this.tags -} diff --git a/infrastructure/modules/vpc/outputs.tf b/infrastructure/modules/vpc/outputs.tf index 56f47a68..f8407110 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -163,15 +163,6 @@ output "flow_log_iam_role_arn" { value = module.flow_log.iam_role_arn } -################################################################ -# VPC Endpoints -################################################################ - -output "vpc_endpoints" { - description = "Map of VPC endpoints created, keyed by the logical name." - value = module.vpc_endpoints.endpoints -} - ################################################################ # Edge route table ################################################################ diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 1134e77d..24bc7bf1 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -307,37 +307,4 @@ variable "iam_role_tags" { ################################################################ # VPC Endpoints -################################################################ - -variable "create_vpc_endpoints" { - description = "Whether to create VPC endpoints." - type = bool - default = true -} -variable "vpc_endpoints" { - description = <<-EOT - Map of VPC endpoints to create. Each key is a logical name, - each value is passed through to the upstream vpc-endpoints - submodule. - - Interface endpoints are placed in intra subnets by default. - Security groups must be created at the stack level and passed - per-endpoint via `security_group_ids`. - - Gateway endpoints require `service_type = "Gateway"` and - `route_table_ids`. - - Supported per-endpoint attributes: - service - AWS service name (e.g. "s3", "ecr.api") - service_type - "Interface" (default) or "Gateway" - policy - JSON endpoint policy document - subnet_ids - Override default intra subnets - security_group_ids - Security group IDs for this endpoint - private_dns_enabled - Enable private DNS (Interface only) - route_table_ids - Route table IDs (Gateway only) - tags - Per-endpoint tags - EOT - type = any - default = {} -} diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 2913a4f1..d35247e6 100644 --- a/scripts/config/generate-available-modules.yaml +++ b/scripts/config/generate-available-modules.yaml @@ -181,12 +181,16 @@ vpc: description: "VPC with subnets, routing, and gateways" wraps: "terraform-aws-modules/vpc/aws" +vpc-endpoint: + description: "VPC endpoints (Interface and Gateway) with policies" + wraps: "terraform-aws-modules/vpc/aws//modules/vpc-endpoints" + vpce: - description: "VPC endpoint (single service)" + description: "VPC endpoint (single service) (legacy)" wraps: "—" vpces: - description: "VPC endpoints (multiple services)" + description: "VPC endpoints (multiple services) (legacy)" wraps: "—" waf: From b1a88acc239a786009f31098633fce8b5baa0f44 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Wed, 22 Jul 2026 11:24:39 +0100 Subject: [PATCH 14/26] fix(vpc-endpoint): enforce validation for non-empty endpoints in variables --- infrastructure/modules/vpc-endpoint/README.md | 9 ++-- .../modules/vpc-endpoint/outputs.tf | 40 ++++++++++++++ .../modules/vpc-endpoint/validations.tf | 52 +------------------ .../modules/vpc-endpoint/variables.tf | 5 ++ 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/infrastructure/modules/vpc-endpoint/README.md b/infrastructure/modules/vpc-endpoint/README.md index f494a0aa..f6e5e1d6 100644 --- a/infrastructure/modules/vpc-endpoint/README.md +++ b/infrastructure/modules/vpc-endpoint/README.md @@ -630,7 +630,6 @@ No resources. | [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 | | [endpoints](#input\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints submodule.

**Interface endpoints** (default):
- Placed in intra subnets by default (can override via subnet\_ids)
- Require security\_group\_ids
- Support optional private\_dns\_enabled (default true)

**Gateway endpoints**:
- Must specify service\_type = "Gateway"
- Require route\_table\_ids
- Do NOT use security\_group\_ids or subnet\_ids

Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api") [REQUIRED]
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document (optional but recommended)
subnet\_ids - Override default intra subnets (optional; Interface only)
security\_group\_ids - Security group IDs (required for Interface endpoints)
private\_dns\_enabled - Enable private DNS (Interface only; default true)
route\_table\_ids - Route table IDs (required for Gateway endpoints)
tags - Per-endpoint tags (optional)

**Security consideration:** Endpoint policies restrict access. If not specified,
the endpoint allows all principals. Recommended to set restrictive policies. | `any` | `{}` | no | -| [endpoints\_not\_empty](#input\_endpoints\_not\_empty) | Internal validation: endpoints must not be empty | `any` | `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 | | [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 | @@ -662,10 +661,10 @@ No resources. | ---- | ----------- | | [endpoints](#output\_endpoints) | Map of VPC endpoints created, keyed by logical name. Contains all endpoint attributes (id, arn, network\_interface\_ids, subnet\_ids, security\_groups, etc.). | | [security\_groups](#output\_security\_groups) | Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create\_security\_group=false). | -| [validate\_endpoint\_policies](#output\_validate\_endpoint\_policies) | Informational output: Gateway endpoint policy coverage | -| [validate\_endpoints\_not\_empty](#output\_validate\_endpoints\_not\_empty) | Informational output: Confirms endpoints are specified | -| [validate\_gateway\_endpoint\_config](#output\_validate\_gateway\_endpoint\_config) | Validation output: Ensures Gateway endpoint configuration | -| [validate\_security\_group\_coverage](#output\_validate\_security\_group\_coverage) | Validation output: Ensures Interface endpoints have security group coverage | +| [validate\_endpoint\_policies](#output\_validate\_endpoint\_policies) | Informational: Policy coverage for Gateway endpoints. Both Gateway and Interface endpoints support policies; Gateway endpoints (S3, DynamoDB) should have restrictive policies as a security best practice. | +| [validate\_endpoints\_not\_empty](#output\_validate\_endpoints\_not\_empty) | Informational: Confirms endpoints are specified | +| [validate\_gateway\_endpoint\_config](#output\_validate\_gateway\_endpoint\_config) | Validation status: Ensures Gateway endpoints have route\_table\_ids and do not have security\_group\_ids | +| [validate\_security\_group\_coverage](#output\_validate\_security\_group\_coverage) | Validation status: Ensures Interface endpoints have security group coverage (either per-endpoint security\_group\_ids or module-level var.security\_group\_id as default) | diff --git a/infrastructure/modules/vpc-endpoint/outputs.tf b/infrastructure/modules/vpc-endpoint/outputs.tf index 4d577720..20314562 100644 --- a/infrastructure/modules/vpc-endpoint/outputs.tf +++ b/infrastructure/modules/vpc-endpoint/outputs.tf @@ -4,6 +4,9 @@ # Expose all VPC endpoints created by the upstream module. # For advanced use cases (e.g., extracting specific endpoint IDs), # callers can access full endpoint attributes via this output. +# +# Validation outputs below provide status and enforce preconditions +# on endpoint configuration (security groups, policies, route tables). ################################################################ output "endpoints" { @@ -15,3 +18,40 @@ output "security_groups" { description = "Map of security groups associated with endpoints. Only populated if upstream module created security groups (not applicable to this wrapper, which sets create_security_group=false)." value = try(module.vpc_endpoints.security_groups, {}) } + +# Validation outputs: Provide status and enforce preconditions on endpoint configuration + +output "validate_security_group_coverage" { + description = "Validation status: Ensures Interface endpoints have security group coverage (either per-endpoint security_group_ids or module-level var.security_group_id as default)" + value = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null ? "Valid: Security group coverage satisfied" : "Invalid: See error above" + + precondition { + condition = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null + error_message = "Interface endpoints (${join(", ", local.interface_endpoints_without_sgs)}) must have security_group_ids specified per-endpoint or use var.security_group_id as default." + } +} + +output "validate_gateway_endpoint_config" { + description = "Validation status: Ensures Gateway endpoints have route_table_ids and do not have security_group_ids" + value = (length(local.gateway_endpoints_with_sgs) == 0 && length(local.gateway_endpoints_without_rtb) == 0) ? "Valid: Gateway endpoint configuration satisfied" : "Invalid: See error above" + + precondition { + condition = length(local.gateway_endpoints_with_sgs) == 0 + error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_with_sgs)}) do not support security_group_ids. Remove this attribute or change service_type to Interface." + } + + precondition { + condition = length(local.gateway_endpoints_without_rtb) == 0 + error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_without_rtb)}) require route_table_ids to specify which route tables to associate with." + } +} + +output "validate_endpoint_policies" { + description = "Informational: Policy coverage for Gateway endpoints. Both Gateway and Interface endpoints support policies; Gateway endpoints (S3, DynamoDB) should have restrictive policies as a security best practice." + value = length(local.gateway_endpoints_without_policies) == 0 ? "All Gateway endpoints have policies" : "Warning: Gateway endpoints (${join(", ", local.gateway_endpoints_without_policies)}) do not have restrictive policies. Recommended for security." +} + +output "validate_endpoints_not_empty" { + description = "Informational: Confirms endpoints are specified" + value = "Endpoints defined" +} diff --git a/infrastructure/modules/vpc-endpoint/validations.tf b/infrastructure/modules/vpc-endpoint/validations.tf index b8a5f14b..236cf09f 100644 --- a/infrastructure/modules/vpc-endpoint/validations.tf +++ b/infrastructure/modules/vpc-endpoint/validations.tf @@ -32,53 +32,5 @@ locals { ] } -# Validation output: Ensures Interface endpoints have security group coverage -output "validate_security_group_coverage" { - value = null - - precondition { - condition = length(local.interface_endpoints_without_sgs) == 0 || var.security_group_id != null - error_message = "Interface endpoints (${join(", ", local.interface_endpoints_without_sgs)}) must have security_group_ids specified per-endpoint or use var.security_group_id as default." - } -} - -# Validation output: Ensures Gateway endpoint configuration -output "validate_gateway_endpoint_config" { - value = null - - precondition { - condition = length(local.gateway_endpoints_with_sgs) == 0 - error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_with_sgs)}) do not support security_group_ids. Remove this attribute or change service_type to Interface." - } - - precondition { - condition = length(local.gateway_endpoints_without_rtb) == 0 - error_message = "Gateway endpoints (${join(", ", local.gateway_endpoints_without_rtb)}) require route_table_ids to specify which route tables to associate with." - } -} - -# Ensure at least one endpoint is specified -variable "endpoints_not_empty" { - type = any - default = null - description = "Internal validation: endpoints must not be empty" - - validation { - condition = length(var.endpoints) > 0 - error_message = "At least one endpoint must be specified. If vpc-endpoint module is not needed, remove it from the configuration." - } -} - -# Note: Both Gateway and Interface endpoints support policies. -# Gateway endpoints (S3, DynamoDB) should use restrictive policies as a security best practice. -# Interface endpoints (SNS, SQS, Secrets Manager, etc.) can optionally use policies for fine-grained access control. -output "validate_endpoint_policies" { - value = length(local.gateway_endpoints_without_policies) == 0 ? "All Gateway endpoints have policies" : "Warning: Gateway endpoints (${join(", ", local.gateway_endpoints_without_policies)}) do not have restrictive policies. Recommended for security." - description = "Informational output: Gateway endpoint policy coverage" -} - -# Validation output: Indicates endpoints are specified -output "validate_endpoints_not_empty" { - value = "Endpoints defined" - description = "Informational output: Confirms endpoints are specified" -} +# Validation preconditions are now in outputs.tf +# This file contains locals and variable validations only. diff --git a/infrastructure/modules/vpc-endpoint/variables.tf b/infrastructure/modules/vpc-endpoint/variables.tf index 8bcfb55e..d08e8cd7 100644 --- a/infrastructure/modules/vpc-endpoint/variables.tf +++ b/infrastructure/modules/vpc-endpoint/variables.tf @@ -90,4 +90,9 @@ variable "endpoints" { ]) error_message = "Gateway endpoints must specify route_table_ids with at least one route table." } + + validation { + condition = length(var.endpoints) > 0 + error_message = "At least one endpoint must be specified. If vpc-endpoint module is not needed, remove it from the configuration." + } } From 56b7b49a157d0ca197e916afcb338ade34fc57fb Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 10:24:49 +0100 Subject: [PATCH 15/26] feat(vpc): add subnet creation flags and input validations for subnet types --- infrastructure/modules/vpc/locals.tf | 36 +++++++++----- infrastructure/modules/vpc/main.tf | 8 ++-- infrastructure/modules/vpc/validations.tf | 58 +++++++++++++++++++++++ infrastructure/modules/vpc/variables.tf | 31 ++++++++++++ 4 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 infrastructure/modules/vpc/validations.tf diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index 41282ef8..fa771b4e 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -23,20 +23,34 @@ locals { private_newbits = var.private_subnet_prefix - local.vpc_prefix_length intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length - # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N] - # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs. + # Build a flat list of newbits, conditionally including only enabled subnet types. + # cidrsubnets() only carves address space for subnet types that are actually created, + # allowing minimal VPCs (e.g., intra-only from a /24 CIDR). auto_newbits = concat( - [for _ in range(local.az_count) : local.firewall_newbits], - [for _ in range(local.az_count) : local.public_newbits], - [for _ in range(local.az_count) : local.private_newbits], - [for _ in range(local.az_count) : local.intra_newbits], + var.create_firewall_subnets ? [for _ in range(local.az_count) : local.firewall_newbits] : [], + var.create_public_subnets ? [for _ in range(local.az_count) : local.public_newbits] : [], + var.create_private_subnets ? [for _ in range(local.az_count) : local.private_newbits] : [], + var.create_intra_subnets ? [for _ in range(local.az_count) : local.intra_newbits] : [], ) - auto_subnets = cidrsubnets(var.vpc_cidr, local.auto_newbits...) + auto_subnets = length(local.auto_newbits) > 0 ? cidrsubnets(var.vpc_cidr, local.auto_newbits...) : [] - auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count) - auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count) - auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count) - auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) + # Calculate slice boundaries dynamically based on which subnet types are enabled + firewall_start = 0 + firewall_end = var.create_firewall_subnets ? local.az_count : 0 + + public_start = local.firewall_end + public_end = local.public_start + (var.create_public_subnets ? local.az_count : 0) + + private_start = local.public_end + private_end = local.private_start + (var.create_private_subnets ? local.az_count : 0) + + intra_start = local.private_end + intra_end = local.intra_start + (var.create_intra_subnets ? local.az_count : 0) + + auto_firewall_subnets = var.create_firewall_subnets ? slice(local.auto_subnets, local.firewall_start, local.firewall_end) : [] + auto_public_subnets = var.create_public_subnets ? slice(local.auto_subnets, local.public_start, local.public_end) : [] + auto_private_subnets = var.create_private_subnets ? slice(local.auto_subnets, local.private_start, local.private_end) : [] + auto_intra_subnets = var.create_intra_subnets ? slice(local.auto_subnets, local.intra_start, local.intra_end) : [] # Allow explicit overrides per tier firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index c2e4c6fd..3e7f2a21 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -75,22 +75,22 @@ module "vpc" { check "subnet_prefix_vs_vpc_prefix" { assert { - condition = var.firewall_subnet_prefix > local.vpc_prefix_length + condition = !var.create_firewall_subnets || var.firewall_subnet_prefix > local.vpc_prefix_length error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." } assert { - condition = var.public_subnet_prefix > local.vpc_prefix_length + condition = !var.create_public_subnets || var.public_subnet_prefix > local.vpc_prefix_length error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." } assert { - condition = var.private_subnet_prefix > local.vpc_prefix_length + condition = !var.create_private_subnets || var.private_subnet_prefix > local.vpc_prefix_length error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." } assert { - condition = var.intra_subnet_prefix > local.vpc_prefix_length + condition = !var.create_intra_subnets || var.intra_subnet_prefix > local.vpc_prefix_length error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." } } diff --git a/infrastructure/modules/vpc/validations.tf b/infrastructure/modules/vpc/validations.tf new file mode 100644 index 00000000..4614625b --- /dev/null +++ b/infrastructure/modules/vpc/validations.tf @@ -0,0 +1,58 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * At least one subnet type must be enabled (create_* flag = true) +# * enable_network_firewall requires firewall subnets to be created +# * single_nat_gateway only makes sense if private subnets exist +# * Explicit subnet CIDR lists must have correct length (equal to az_count) +# +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = ( + var.create_firewall_subnets || + var.create_public_subnets || + var.create_private_subnets || + var.create_intra_subnets + ) + error_message = "At least one subnet type must be created (set one or more of: create_firewall_subnets, create_public_subnets, create_private_subnets, create_intra_subnets to true)." + } + + precondition { + condition = !var.enable_network_firewall || var.create_firewall_subnets + error_message = "enable_network_firewall = true requires create_firewall_subnets = true." + } + + precondition { + condition = !var.single_nat_gateway || var.create_private_subnets + error_message = "single_nat_gateway = true requires create_private_subnets = true (NAT gateway must route traffic to private subnets)." + } + + precondition { + condition = length(var.firewall_subnets) == 0 || length(var.firewall_subnets) == local.az_count + error_message = "firewall_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.firewall_subnets)}." + } + + precondition { + condition = length(var.public_subnets) == 0 || length(var.public_subnets) == local.az_count + error_message = "public_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.public_subnets)}." + } + + precondition { + condition = length(var.private_subnets) == 0 || length(var.private_subnets) == local.az_count + error_message = "private_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.private_subnets)}." + } + + precondition { + condition = length(var.intra_subnets) == 0 || length(var.intra_subnets) == local.az_count + error_message = "intra_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.intra_subnets)}." + } + } +} diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 24bc7bf1..9ea6c206 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -54,6 +54,37 @@ variable "availability_zones" { default = null } +################################################################ +# Subnet type creation flags +# +# Control which subnet tiers are created. When a subnet type +# is disabled, its prefix and CIDR calculations are skipped. +################################################################ + +variable "create_firewall_subnets" { + description = "Whether to create firewall subnets (required for Network Firewall routing mode)." + type = bool + default = true +} + +variable "create_public_subnets" { + description = "Whether to create public subnets (internet-facing resources, NAT gateways)." + type = bool + default = true +} + +variable "create_private_subnets" { + description = "Whether to create private subnets (workloads with outbound internet access via NAT gateway)." + type = bool + default = true +} + +variable "create_intra_subnets" { + description = "Whether to create intra subnets (no internet access)." + type = bool + default = true +} + variable "firewall_subnet_prefix" { description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs." type = number From 66bcbfa07b41f2538e2d9f1471936cc276594db8 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 10:28:07 +0100 Subject: [PATCH 16/26] fix(vpc): enhance input validation and update README for subnet prefix constraints --- infrastructure/modules/vpc/README.md | 18 ++++---- infrastructure/modules/vpc/main.tf | 25 +----------- infrastructure/modules/vpc/validations.tf | 50 ++++++++++++++++++++--- infrastructure/modules/vpc/variables.tf | 25 ++++++------ 4 files changed, 69 insertions(+), 49 deletions(-) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 2e290a8c..41c3fc7a 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -103,6 +103,7 @@ module "vpc" { | Name | Version | | ---- | ------- | | [aws](#provider\_aws) | 6.51.0 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -111,7 +112,6 @@ module "vpc" { | [flow\_log](#module\_flow\_log) | terraform-aws-modules/vpc/aws//modules/flow-log | 6.6.1 | | [this](#module\_this) | ../tags | n/a | | [vpc](#module\_vpc) | terraform-aws-modules/vpc/aws | 6.6.1 | -| [vpc\_endpoints](#module\_vpc\_endpoints) | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | 6.6.1 | ## Resources @@ -124,6 +124,7 @@ module "vpc" { | [aws_route_table_association.edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | | [aws_route_table_association.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | | [aws_subnet.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [aws_availability_zones.available](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zones) | data source | ## Inputs @@ -137,7 +138,10 @@ module "vpc" { | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | | [cloudwatch\_log\_group\_tags](#input\_cloudwatch\_log\_group\_tags) | Additional tags for the CloudWatch log group. | `map(string)` | `{}` | 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 | -| [create\_vpc\_endpoints](#input\_create\_vpc\_endpoints) | Whether to create VPC endpoints. | `bool` | `true` | no | +| [create\_firewall\_subnets](#input\_create\_firewall\_subnets) | Whether to create firewall subnets (required for Network Firewall routing mode). | `bool` | `true` | no | +| [create\_intra\_subnets](#input\_create\_intra\_subnets) | Whether to create intra subnets (no internet access). | `bool` | `true` | no | +| [create\_private\_subnets](#input\_create\_private\_subnets) | Whether to create private subnets (workloads with outbound internet access via NAT gateway). | `bool` | `true` | no | +| [create\_public\_subnets](#input\_create\_public\_subnets) | Whether to create public subnets (internet-facing resources, NAT gateways). | `bool` | `true` | 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 | @@ -153,7 +157,7 @@ module "vpc" { | [enable\_network\_firewall](#input\_enable\_network\_firewall) | When true, the VPC module creates firewall subnets, takes over
IGW management from the community module, and reconfigures
routing for AWS Network Firewall inspection:
- Firewall subnets created as standalone resources
- IGW created as a standalone resource (community module's create\_igw = false)
- Firewall subnets get a default route (0.0.0.0/0) to the IGW
- Public subnet default route is NOT created (callers must
inject 0.0.0.0/0 → firewall VPCE at the stack level)
When false (default), no firewall subnets are created, the
community module creates the IGW and public → IGW route as
normal — no Network Firewall in the path. | `bool` | `false` | 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 | -| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs. | `number` | `28` | no | +| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when firewall\_subnets list is empty; when explicit firewall\_subnets are provided, this value is ignored. Highly recommended: /28 to minimize wasted IPs. | `number` | `28` | no | | [firewall\_subnet\_tags](#input\_firewall\_subnet\_tags) | Additional tags for the firewall subnets. | `map(string)` | `{}` | no | | [firewall\_subnets](#input\_firewall\_subnets) | Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | | [flow\_log\_kms\_key\_id](#input\_flow\_log\_kms\_key\_id) | ARN of a KMS key to encrypt the CloudWatch log group. Leave null for no encryption. | `string` | `null` | no | @@ -163,7 +167,7 @@ module "vpc" { | [flow\_log\_traffic\_type](#input\_flow\_log\_traffic\_type) | The type of traffic to capture. Valid values: ACCEPT, REJECT, ALL. | `string` | `"ALL"` | no | | [iam\_role\_tags](#input\_iam\_role\_tags) | Additional tags for the IAM role used by the VPC flow log. | `map(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 | -| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no | +| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when intra\_subnets list is empty; when explicit intra\_subnets are provided, this value is ignored. | `number` | `23` | no | | [intra\_subnet\_tags](#input\_intra\_subnet\_tags) | Additional tags for the intra (no-internet) subnets. | `map(string)` | `{}` | no | | [intra\_subnets](#input\_intra\_subnets) | Explicit CIDR blocks for intra subnets with no internet route (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | 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 | @@ -176,12 +180,12 @@ module "vpc" { | [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 | -| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no | +| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when private\_subnets list is empty; when explicit private\_subnets are provided, this value is ignored. | `number` | `23` | no | | [private\_subnet\_tags](#input\_private\_subnet\_tags) | Additional tags for the private (NAT-routed) subnets. | `map(string)` | `{}` | no | | [private\_subnets](#input\_private\_subnets) | Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | 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 | -| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `24` | no | +| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc\_cidr when auto-calculating. Used only when public\_subnets list is empty; when explicit public\_subnets are provided, this value is ignored. | `number` | `24` | no | | [public\_subnet\_tags](#input\_public\_subnet\_tags) | Additional tags for the public subnets. | `map(string)` | `{}` | no | | [public\_subnets](#input\_public\_subnets) | Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | 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 | @@ -195,7 +199,6 @@ module "vpc" { | [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\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC (AWS allows /16 to /28 netmask). Subnet CIDR blocks are auto-calculated from this VPC CIDR using the *\_subnet\_prefix variables. | `string` | n/a | yes | -| [vpc\_endpoints](#input\_vpc\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints
submodule.

Interface endpoints are placed in intra subnets by default.
Security groups must be created at the stack level and passed
per-endpoint via `security_group_ids`.

Gateway endpoints require `service_type = "Gateway"` and
`route_table_ids`.

Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api")
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document
subnet\_ids - Override default intra subnets
security\_group\_ids - Security group IDs for this endpoint
private\_dns\_enabled - Enable private DNS (Interface only)
route\_table\_ids - Route table IDs (Gateway only)
tags - Per-endpoint tags | `any` | `{}` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs @@ -227,7 +230,6 @@ module "vpc" { | [public\_subnets\_cidr\_blocks](#output\_public\_subnets\_cidr\_blocks) | List of CIDR blocks of the public subnets. | | [vpc\_arn](#output\_vpc\_arn) | The ARN of the VPC. | | [vpc\_cidr\_block](#output\_vpc\_cidr\_block) | The primary CIDR block of the VPC. | -| [vpc\_endpoints](#output\_vpc\_endpoints) | Map of VPC endpoints created, keyed by the logical name. | | [vpc\_id](#output\_vpc\_id) | The ID of the VPC. | diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 3e7f2a21..13067a5c 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -10,6 +10,8 @@ # intra – no internet route (default /23) # # Naming and tagging are derived from context.tf via module.this. +# +# Cross-variable input constraints are enforced in validations.tf. ################################################################ module "vpc" { @@ -73,28 +75,6 @@ module "vpc" { tags = { for k, v in module.this.tags : k => v if k != "Name" } } -check "subnet_prefix_vs_vpc_prefix" { - assert { - condition = !var.create_firewall_subnets || var.firewall_subnet_prefix > local.vpc_prefix_length - error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." - } - - assert { - condition = !var.create_public_subnets || var.public_subnet_prefix > local.vpc_prefix_length - error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." - } - - assert { - condition = !var.create_private_subnets || var.private_subnet_prefix > local.vpc_prefix_length - error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." - } - - assert { - condition = !var.create_intra_subnets || var.intra_subnet_prefix > local.vpc_prefix_length - error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." - } -} - ################################################################ # Firewall subnets # @@ -237,4 +217,3 @@ module "flow_log" { tags = module.this.tags } - diff --git a/infrastructure/modules/vpc/validations.tf b/infrastructure/modules/vpc/validations.tf index 4614625b..936b235e 100644 --- a/infrastructure/modules/vpc/validations.tf +++ b/infrastructure/modules/vpc/validations.tf @@ -5,8 +5,11 @@ # through individual variable validation blocks: # # * At least one subnet type must be enabled (create_* flag = true) +# * Subnet prefix must be more specific than VPC CIDR (only when +# auto-calculating that subnet type — if explicit subnets are +# provided, the prefix is ignored) # * enable_network_firewall requires firewall subnets to be created -# * single_nat_gateway only makes sense if private subnets exist +# * single_nat_gateway requires private subnets to be created # * Explicit subnet CIDR lists must have correct length (equal to az_count) # ################################################################ @@ -25,6 +28,43 @@ resource "terraform_data" "validations" { error_message = "At least one subnet type must be created (set one or more of: create_firewall_subnets, create_public_subnets, create_private_subnets, create_intra_subnets to true)." } + # Prefix validations: only apply when auto-calculating (explicit subnets are empty) + precondition { + condition = ( + !var.create_firewall_subnets || + length(var.firewall_subnets) > 0 || + var.firewall_subnet_prefix > local.vpc_prefix_length + ) + error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating firewall subnets (firewall_subnets is empty). When using explicit firewall_subnets, the prefix is ignored." + } + + precondition { + condition = ( + !var.create_public_subnets || + length(var.public_subnets) > 0 || + var.public_subnet_prefix > local.vpc_prefix_length + ) + error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating public subnets (public_subnets is empty). When using explicit public_subnets, the prefix is ignored." + } + + precondition { + condition = ( + !var.create_private_subnets || + length(var.private_subnets) > 0 || + var.private_subnet_prefix > local.vpc_prefix_length + ) + error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating private subnets (private_subnets is empty). When using explicit private_subnets, the prefix is ignored." + } + + precondition { + condition = ( + !var.create_intra_subnets || + length(var.intra_subnets) > 0 || + var.intra_subnet_prefix > local.vpc_prefix_length + ) + error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than VPC CIDR (/${local.vpc_prefix_length}) when auto-calculating intra subnets (intra_subnets is empty). When using explicit intra_subnets, the prefix is ignored." + } + precondition { condition = !var.enable_network_firewall || var.create_firewall_subnets error_message = "enable_network_firewall = true requires create_firewall_subnets = true." @@ -36,22 +76,22 @@ resource "terraform_data" "validations" { } precondition { - condition = length(var.firewall_subnets) == 0 || length(var.firewall_subnets) == local.az_count + condition = length(var.firewall_subnets) == 0 || length(var.firewall_subnets) == local.az_count error_message = "firewall_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.firewall_subnets)}." } precondition { - condition = length(var.public_subnets) == 0 || length(var.public_subnets) == local.az_count + condition = length(var.public_subnets) == 0 || length(var.public_subnets) == local.az_count error_message = "public_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.public_subnets)}." } precondition { - condition = length(var.private_subnets) == 0 || length(var.private_subnets) == local.az_count + condition = length(var.private_subnets) == 0 || length(var.private_subnets) == local.az_count error_message = "private_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.private_subnets)}." } precondition { - condition = length(var.intra_subnets) == 0 || length(var.intra_subnets) == local.az_count + condition = length(var.intra_subnets) == 0 || length(var.intra_subnets) == local.az_count error_message = "intra_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.intra_subnets)}." } } diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 9ea6c206..253c8a46 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -86,46 +86,46 @@ variable "create_intra_subnets" { } variable "firewall_subnet_prefix" { - description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs." + description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when firewall_subnets list is empty; when explicit firewall_subnets are provided, this value is ignored. Highly recommended: /28 to minimize wasted IPs." type = number default = 28 validation { - condition = length(var.firewall_subnets) == 0 ? (var.firewall_subnet_prefix >= 16 && var.firewall_subnet_prefix <= 28) : true - error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + condition = var.firewall_subnet_prefix >= 16 && var.firewall_subnet_prefix <= 28 + error_message = "firewall_subnet_prefix must be between /16 and /28 per AWS limits." } } variable "public_subnet_prefix" { - description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." + description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when public_subnets list is empty; when explicit public_subnets are provided, this value is ignored." type = number default = 24 validation { - condition = length(var.public_subnets) == 0 ? (var.public_subnet_prefix >= 16 && var.public_subnet_prefix <= 28) : true - error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + condition = var.public_subnet_prefix >= 16 && var.public_subnet_prefix <= 28 + error_message = "public_subnet_prefix must be between /16 and /28 per AWS limits." } } variable "private_subnet_prefix" { - description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." + description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when private_subnets list is empty; when explicit private_subnets are provided, this value is ignored." type = number default = 23 validation { - condition = length(var.private_subnets) == 0 ? (var.private_subnet_prefix >= 16 && var.private_subnet_prefix <= 28) : true - error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + condition = var.private_subnet_prefix >= 16 && var.private_subnet_prefix <= 28 + error_message = "private_subnet_prefix must be between /16 and /28 per AWS limits." } } variable "intra_subnet_prefix" { - description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." + description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28. Must be more specific (larger numerically) than vpc_cidr when auto-calculating. Used only when intra_subnets list is empty; when explicit intra_subnets are provided, this value is ignored." type = number default = 23 validation { - condition = length(var.intra_subnets) == 0 ? (var.intra_subnet_prefix >= 16 && var.intra_subnet_prefix <= 28) : true - error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + condition = var.intra_subnet_prefix >= 16 && var.intra_subnet_prefix <= 28 + error_message = "intra_subnet_prefix must be between /16 and /28 per AWS limits." } } @@ -338,4 +338,3 @@ variable "iam_role_tags" { ################################################################ # VPC Endpoints - From 78b714ca87149729dfc4da6331c793b9f1f78f01 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 10:31:35 +0100 Subject: [PATCH 17/26] docs(vpc): update README usage examples with new subnet creation parameters --- infrastructure/modules/vpc/README.md | 88 +++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 41c3fc7a..8193f548 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -48,26 +48,110 @@ Subnet CIDRs are auto-calculated from the VPC CIDR across the first three availa ## Usage +### Standard VPC (all subnet tiers) + ```terraform module "vpc" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" - environment = "dev" + environment = "prod" service = "bcss" name = "vpc" vpc_cidr = "10.0.0.0/16" - single_nat_gateway = true # cost saving for non-prod + single_nat_gateway = false # one NAT per AZ for HA flow_log_kms_key_id = aws_kms_key.cloudwatch.arn # optional encryption } ``` +### Database VPC (intra subnets only, /24 CIDR) + +Minimal VPC for databases with no internet access. Uses /26 intra subnets (64 IPs × 3 AZs). + +```terraform +module "database_vpc" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" + + environment = "prod" + service = "bcss" + name = "database" + + vpc_cidr = "10.1.0.0/24" + + # Intra subnets only — disable other tiers + create_firewall_subnets = false + create_public_subnets = false + create_private_subnets = false + create_intra_subnets = true + + # Adjust subnet prefix for /24 VPC (must be larger than /24, e.g., /26, /27, /28) + intra_subnet_prefix = 26 + + enable_flow_log = true + flow_log_retention_in_days = 30 +} +``` + +### Network Firewall routing (firewall + public subnets only) + +For centralized Network Firewall inspection. Public subnets route outbound traffic via firewall VPCE (not IGW). + +```terraform +module "vpc_nfw" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" + + environment = "prod" + service = "bcss" + name = "nfw" + + vpc_cidr = "10.0.0.0/16" + + # Firewall + public only — disable private and intra + create_firewall_subnets = true + create_public_subnets = true + create_private_subnets = false + create_intra_subnets = false + + enable_network_firewall = true # enables firewall routing mode + + # Inject 0.0.0.0/0 → firewall VPCE into public route tables at stack level +} +``` + +### Minimal public-only scenario + +For simple public-facing resources without private egress. + +```terraform +module "vpc_public" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" + + environment = "dev" + service = "test" + name = "public" + + vpc_cidr = "10.0.0.0/16" + + # Public subnets only + create_firewall_subnets = false + create_public_subnets = true + create_private_subnets = false + create_intra_subnets = false + + map_public_ip_on_launch = true # auto-assign public IPs +} +``` + ## Key variables | Variable | Description | Default | | --- | --- | --- | | `vpc_cidr` | VPC CIDR block (/16 to /28 per AWS limits) | Required | +| `create_firewall_subnets` | Whether to create firewall subnets (required for Network Firewall routing mode) | `true` | +| `create_public_subnets` | Whether to create public subnets (internet-facing resources, NAT gateways) | `true` | +| `create_private_subnets` | Whether to create private subnets (NAT-routed workloads with internet access) | `true` | +| `create_intra_subnets` | Whether to create intra subnets (no internet access) | `true` | | `availability_zones` | Explicit AZs for subnet placement; defaults to the first three available AZs | `null` | | `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` | | `enable_flow_log` | Enable VPC flow logs | `true` | From 0246c4e4f9282adca8157f7f418ace47a920393b Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 10:49:39 +0100 Subject: [PATCH 18/26] feat(vpc): add NAT Gateway configuration and validation for private subnets --- infrastructure/modules/vpc/README.md | 3 ++- infrastructure/modules/vpc/main.tf | 2 +- infrastructure/modules/vpc/validations.tf | 5 +++++ infrastructure/modules/vpc/variables.tf | 8 +++++++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 8193f548..24346f0c 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -238,6 +238,7 @@ module "vpc_public" { | [enable\_dns\_hostnames](#input\_enable\_dns\_hostnames) | Enable DNS hostnames in the VPC. | `bool` | `true` | no | | [enable\_dns\_support](#input\_enable\_dns\_support) | Enable DNS support in the VPC. | `bool` | `true` | no | | [enable\_flow\_log](#input\_enable\_flow\_log) | Enable VPC flow logs to CloudWatch Logs. | `bool` | `true` | no | +| [enable\_nat\_gateway](#input\_enable\_nat\_gateway) | Provision NAT Gateway(s) for private subnet internet egress. Not applicable if private subnets are disabled. Set to false for database-only VPCs with no internet-routed workloads. | `bool` | `true` | no | | [enable\_network\_firewall](#input\_enable\_network\_firewall) | When true, the VPC module creates firewall subnets, takes over
IGW management from the community module, and reconfigures
routing for AWS Network Firewall inspection:
- Firewall subnets created as standalone resources
- IGW created as a standalone resource (community module's create\_igw = false)
- Firewall subnets get a default route (0.0.0.0/0) to the IGW
- Public subnet default route is NOT created (callers must
inject 0.0.0.0/0 → firewall VPCE at the stack level)
When false (default), no firewall subnets are created, the
community module creates the IGW and public → IGW route as
normal — no Network Firewall in the path. | `bool` | `false` | 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 | @@ -276,7 +277,7 @@ module "vpc_public" { | [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 | -| [single\_nat\_gateway](#input\_single\_nat\_gateway) | Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. | `bool` | `false` | no | +| [single\_nat\_gateway](#input\_single\_nat\_gateway) | Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. Requires enable\_nat\_gateway = true and create\_private\_subnets = true. | `bool` | `false` | 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 | diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 13067a5c..724f569e 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -40,7 +40,7 @@ module "vpc" { create_multiple_public_route_tables = var.enable_network_firewall # NAT gateway configuration - enable_nat_gateway = true + enable_nat_gateway = var.enable_nat_gateway single_nat_gateway = var.single_nat_gateway one_nat_gateway_per_az = !var.single_nat_gateway diff --git a/infrastructure/modules/vpc/validations.tf b/infrastructure/modules/vpc/validations.tf index 936b235e..df93ece3 100644 --- a/infrastructure/modules/vpc/validations.tf +++ b/infrastructure/modules/vpc/validations.tf @@ -75,6 +75,11 @@ resource "terraform_data" "validations" { error_message = "single_nat_gateway = true requires create_private_subnets = true (NAT gateway must route traffic to private subnets)." } + precondition { + condition = !var.enable_nat_gateway || var.create_private_subnets + error_message = "enable_nat_gateway = true requires create_private_subnets = true. For database-only or intra-only VPCs without private subnets, set enable_nat_gateway = false." + } + precondition { condition = length(var.firewall_subnets) == 0 || length(var.firewall_subnets) == local.az_count error_message = "firewall_subnets must be empty or have exactly ${local.az_count} entries (one per AZ); found ${length(var.firewall_subnets)}." diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 253c8a46..48f39106 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -164,8 +164,14 @@ variable "intra_subnets" { # NAT Gateway ################################################################ +variable "enable_nat_gateway" { + description = "Provision NAT Gateway(s) for private subnet internet egress. Not applicable if private subnets are disabled. Set to false for database-only VPCs with no internet-routed workloads." + type = bool + default = true +} + variable "single_nat_gateway" { - description = "Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability." + description = "Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. Requires enable_nat_gateway = true and create_private_subnets = true." type = bool default = false } From 353c23da2b227e6013838643d25bba896f031062 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 13:08:03 +0100 Subject: [PATCH 19/26] feat(vpc): add support for VPC Block Public Access exclusions for specific subnets --- infrastructure/modules/vpc/README.md | 1 + infrastructure/modules/vpc/main.tf | 10 ++++++++++ infrastructure/modules/vpc/variables.tf | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 24346f0c..49dd62a2 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -283,6 +283,7 @@ module "vpc_public" { | [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\_block\_public\_access\_exclusions](#input\_vpc\_block\_public\_access\_exclusions) | Map of exclusions to the account-wide VPC Block Public Access policy. Use to exempt specific subnets (e.g., public subnets) when account-wide blocking is enabled. See: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options | `map(any)` | `{}` | no | | [vpc\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC (AWS allows /16 to /28 netmask). Subnet CIDR blocks are auto-calculated from this VPC CIDR using the *\_subnet\_prefix variables. | `string` | n/a | yes | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 724f569e..db9d5ecc 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -71,6 +71,16 @@ module "vpc" { private_subnet_tags = var.private_subnet_tags intra_subnet_tags = var.intra_subnet_tags + # VPC Block Public Access exclusions + # + # Account-wide VPC Block Public Access (aws_vpc_block_public_access_options) must be + # managed outside this module, at the account level: + # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options + # + # Per-VPC subnet exclusions can be specified here to exempt specific subnets from the + # account-wide block (e.g., public subnets that legitimately need internet access). + vpc_block_public_access_exclusions = length(var.vpc_block_public_access_exclusions) > 0 ? var.vpc_block_public_access_exclusions : {} + # Exclude "Name" — the community module sets its own Name tags on all resources tags = { for k, v in module.this.tags : k => v if k != "Name" } } diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 48f39106..936ca933 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -252,6 +252,25 @@ variable "manage_default_network_acl" { default = true } +################################################################ +# VPC Block Public Access +# +# Account-wide Block Public Access (enabling/disabling the policy +# itself) must be managed at the account level using the +# aws_vpc_block_public_access_options resource — NOT in this module: +# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options +# +# This module only exposes per-VPC subnet-level exclusions, allowing +# specific subnets (e.g., public subnets with legitimate internet +# access) to be exempt from an account-wide block. +################################################################ + +variable "vpc_block_public_access_exclusions" { + description = "Map of exclusions to the account-wide VPC Block Public Access policy. Use to exempt specific subnets (e.g., public subnets) when account-wide blocking is enabled. See: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_block_public_access_options" + type = map(any) + default = {} +} + ################################################################ # Subnet tags ################################################################ From 8ab2f2789779e989ff8cb721665fcfe492e4a771 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 13:13:26 +0100 Subject: [PATCH 20/26] fix(vpc-endpoint): update tags assignment to use module context --- infrastructure/modules/vpc-endpoint/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/vpc-endpoint/main.tf b/infrastructure/modules/vpc-endpoint/main.tf index dc0b3d48..e970ea21 100644 --- a/infrastructure/modules/vpc-endpoint/main.tf +++ b/infrastructure/modules/vpc-endpoint/main.tf @@ -25,5 +25,5 @@ module "vpc_endpoints" { # Use merged endpoints map to apply default security_group_id where needed endpoints = local.endpoints_merged - tags = var.tags + tags = module.this.tags } From 56cf33c401a49858f7ebe6e51eedea915f22725d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 24 Jul 2026 14:23:17 +0100 Subject: [PATCH 21/26] fix(vpc-endpoint): update tags assignment to use module context --- infrastructure/modules/vpc-endpoint/README.md | 172 ++++++++++++++++-- infrastructure/modules/vpc-endpoint/locals.tf | 11 +- 2 files changed, 163 insertions(+), 20 deletions(-) diff --git a/infrastructure/modules/vpc-endpoint/README.md b/infrastructure/modules/vpc-endpoint/README.md index f6e5e1d6..6514d9a0 100644 --- a/infrastructure/modules/vpc-endpoint/README.md +++ b/infrastructure/modules/vpc-endpoint/README.md @@ -10,7 +10,7 @@ Thin wrapper around the community [`terraform-aws-modules/vpc/aws//modules/vpc-e | Default security groups | Single `security_group_id` can default all Interface endpoints; per-endpoint override via `security_group_ids` | | Endpoint policies | Module passes through optional policies for both Gateway (S3/DynamoDB) and Interface (SNS, SQS, etc.) endpoints; caller responsible for restrictive policies | | Subnet isolation | Default placement in intra subnets (no internet route); can override per-endpoint | -| Consistent tagging | All resources tagged via `var.tags`; no additional tagging logic | +| Consistent tagging | All resources inherit `var.tags`; each endpoint also gets a default `Name` tag of `${module.this.id}-${endpoint_key}` unless overridden per-endpoint | ## What this module provides @@ -20,10 +20,33 @@ Thin wrapper around the community [`terraform-aws-modules/vpc/aws//modules/vpc-e - **Default subnets** — Single `var.subnet_ids` applies to all endpoints unless overridden per-endpoint - **Per-endpoint overrides** — `security_group_ids`, `subnet_ids`, and `policy` customizable per endpoint - **Per-endpoint policies** — Optional endpoint policies for both Gateway and Interface endpoints (recommended for security) -- **Consistent tagging** — All resources tagged via `var.tags` +- **Consistent tagging** — All resources inherit `var.tags`, and each endpoint gets a default `Name` tag with an optional per-endpoint override ## Usage +### Endpoint naming + +This wrapper adds a per-endpoint `Name` tag automatically: + +- Default: `${module.this.id}-${replace(endpoint_key, ".", "-")}` +- Override: set `name` on the endpoint object + +This is applied by injecting `Name` into the per-endpoint `tags` map before calling the upstream module. + +### Advanced upstream attributes + +This wrapper passes most per-endpoint settings through to the upstream module unchanged. In addition to the common examples below, you can also use upstream-supported attributes such as: + +- `auto_accept` +- `dns_options` +- `ip_address_type` +- `service_endpoint` +- `service_region` +- `subnet_configurations` +- `tags` + +The key constraint from this wrapper is that every endpoint object must still include `service`. + ### Example 1: Minimal Interface endpoints with default security group ```hcl @@ -39,12 +62,36 @@ module "vpc_endpoints" { service = "ecr.api" service_type = "Interface" # Inherits security_group_id from var.security_group_id + # Name tag defaults to "${module.this.id}-ecr_api" } ecr_dkr = { - service = "ecr.api" + service = "ecr.dkr" service_type = "Interface" # Inherits security_group_id from var.security_group_id + # Name tag defaults to "${module.this.id}-ecr_dkr" + } + } + + tags = var.tags +} +``` + +### Example 1a: Override the endpoint `Name` tag + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.vpc_endpoints.id + + endpoints = { + secretsmanager = { + service = "secretsmanager" + service_type = "Interface" + name = "platform-secrets-endpoint" } } @@ -147,7 +194,7 @@ module "vpc_endpoints" { } ``` -### Example 5: Interface endpoint with user-defined IP address (static ENI placement) +### Example 5: Interface endpoint with explicit DNS options and endpoint-specific tags ```hcl module "vpc_endpoints" { @@ -158,13 +205,19 @@ module "vpc_endpoints" { security_group_id = aws_security_group.interface_endpoints.id endpoints = { - ecr_api = { - service = "ecr.api" - service_type = "Interface" - # Fixed IPs in specific subnet (single AZ, lower cost) - subnet_ids = [module.vpc.intra_subnet_ids[0]] + logs = { + service = "logs" + service_type = "Interface" + name = "central-cloudwatch-logs-endpoint" + subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2) private_dns_enabled = true - # Optional: specify exact private IPs via network_interface_ids if needed + dns_options = { + dns_record_ip_type = "ipv4" + } + tags = { + Purpose = "application-observability" + Tier = "shared" + } } } @@ -172,7 +225,86 @@ module "vpc_endpoints" { } ``` -### Example 6: PrivateLink to RDS with policy and security group +### Example 6: Interface endpoint with fixed private IPs via subnet configurations + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = slice(module.vpc.intra_subnet_ids, 0, 2) + security_group_id = aws_security_group.interface_endpoints.id + + endpoints = { + ec2 = { + service = "ec2" + service_type = "Interface" + private_dns_enabled = true + ip_address_type = "ipv4" + subnet_configurations = [ + { + subnet_id = module.vpc.intra_subnet_ids[0] + ipv4 = "10.20.10.10" + }, + { + subnet_id = module.vpc.intra_subnet_ids[1] + ipv4 = "10.20.20.10" + } + ] + } + } + + tags = var.tags +} +``` + +### Example 7: Dual S3 endpoints for VPC and inbound resolver traffic + +```hcl +module "vpc_endpoints" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc-endpoint?ref=v7.0.0" + + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.intra_subnet_ids + security_group_id = aws_security_group.interface_endpoints.id + + endpoints = { + s3_gateway = { + service = "s3" + service_type = "Gateway" + route_table_ids = module.vpc.private_route_table_ids + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = "*" + Action = ["s3:GetObject"] + Resource = "arn:aws:s3:::my-ingestion-bucket/*" + }] + }) + } + + s3_interface = { + service = "s3" + service_type = "Interface" + private_dns_enabled = true + dns_options = { + dns_record_ip_type = "ipv4" + private_dns_only_for_inbound_resolver_endpoint = true + } + tags = { + Purpose = "on-prem-s3-resolution" + } + } + } + + tags = var.tags +} +``` + +This pattern follows the AWS provider guidance for services that support both Gateway and Interface endpoints: VPC traffic uses the Gateway endpoint, while traffic entering through Route 53 Resolver inbound endpoints can use the Interface endpoint. + +### Example 8: PrivateLink to RDS with policy and security group ```hcl module "vpc_endpoints" { @@ -217,7 +349,7 @@ resource "aws_security_group" "rds_endpoint" { } ``` -### Example 7: PrivateLink to EC2 with policy and security group +### Example 9: PrivateLink to EC2 with policy and security group ```hcl module "vpc_endpoints" { @@ -260,7 +392,7 @@ resource "aws_security_group" "ec2_endpoint" { } ``` -### Example 8: PrivateLink to NLB with policy, security group, and private DNS +### Example 10: PrivateLink to ELB API with policy, security group, and private DNS ```hcl module "vpc_endpoints" { @@ -312,7 +444,7 @@ resource "aws_security_group" "nlb_endpoint" { } ``` -### Example 9: Complex: Multiple endpoint types with mixed defaults and overrides +### Example 11: Complex: Multiple endpoint types with mixed defaults and overrides ```hcl module "vpc_endpoints" { @@ -422,14 +554,16 @@ module "vpc_endpoints" { | Type | Required attributes | Optional attributes | | --- | --- | --- | -| Interface | `service`, and either per-endpoint `security_group_ids` OR `var.security_group_id` | `subnet_ids`, `policy`, `private_dns_enabled`, `tags` | -| Gateway | `service`, `route_table_ids` | `policy`, `tags` | +| Interface | `service`, and either per-endpoint `security_group_ids` OR `var.security_group_id` | `auto_accept`, `dns_options`, `ip_address_type`, `policy`, `private_dns_enabled`, `service_endpoint`, `service_region`, `subnet_configurations`, `subnet_ids`, `tags`, `name` | +| Gateway | `service`, `route_table_ids` | `policy`, `tags`, `name` | ### Naming and variable precedence -This module is a pass-through wrapper with no complex naming logic. The caller is responsible for: +The caller is responsible for endpoint keys and service selection. This wrapper also applies endpoint naming conventions: - **Endpoint logical names**: Use clear, descriptive keys in the `endpoints` map (e.g., `ecr_api`, `s3`, `secretsmanager`) +- **Endpoint `Name` tag**: Defaults to `${module.this.id}-${replace(endpoint_key, ".", "-")}` +- **Endpoint `Name` override**: Set `name` on an endpoint to override only that endpoint's `Name` tag - **Service names**: AWS service names (e.g., `s3`, `ecr.api`, `ecr.dkr`) are passed directly to the upstream module - **Subnet placement**: Default to `intra_subnet_ids` (no internet route); override per-endpoint via `subnet_ids` for cost optimization @@ -490,7 +624,7 @@ endpoints = { Without policies, any IAM principal in your account (or federated users) can access any S3 bucket or service through the endpoint. -**Interface endpoints do NOT support policies.** Instead, they use security groups for access control (see below). +**Some Interface endpoints also support policies**, but support is service-specific. Treat security groups as the primary network control, and only rely on endpoint policies where the AWS service documentation explicitly supports them. ### Security groups for Interface endpoints @@ -535,7 +669,7 @@ resource "aws_security_group" "secrets_endpoints" { - **Does NOT create IAM policies** — IAM permissions to consume endpoints are the caller's responsibility - **Does NOT enforce endpoint policies** — Endpoints default to open access; you must supply restrictive policies - **Does NOT manage route table associations** — For Gateway endpoints, you pass existing route tables -- **Does NOT apply custom naming or labels** — Service/endpoint names are passed through as-is +- **Does NOT rename AWS service identifiers** — `service` values are passed through to the upstream module; only the resource `Name` tag is defaulted/overridable - **Does NOT create DNS records** — Private DNS for Interface endpoints is configured via `private_dns_enabled`; caller owns Route53 setup if needed - **Does NOT manage VPCs or subnets** — Caller must provide `vpc_id` and `subnet_ids` from the VPC module diff --git a/infrastructure/modules/vpc-endpoint/locals.tf b/infrastructure/modules/vpc-endpoint/locals.tf index 7ee07d0d..bab7dd47 100644 --- a/infrastructure/modules/vpc-endpoint/locals.tf +++ b/infrastructure/modules/vpc-endpoint/locals.tf @@ -30,7 +30,16 @@ locals { try(v.subnet_ids, null) == null && length(var.subnet_ids) > 0 ? { subnet_ids = var.subnet_ids } : - {} + {}, + # Merge tags: inject a default Name tag of "${module.this.id}-${key}" + # Consumer can override by setting `name` on the endpoint object. + # Per-endpoint tags from v.tags are preserved alongside the injected Name. + { + tags = merge( + try(v.tags, {}), + { Name = try(v.name, "${module.this.id}-${replace(k, ".", "-")}") } + ) + } ) } } From 9eb63b2c0fe9121e2d529039ceadf0727fbf8454 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 27 Jul 2026 13:33:02 +0100 Subject: [PATCH 22/26] feat(rds): add KMS key ID variable for master user password encryption --- infrastructure/modules/rds/README.md | 1 + infrastructure/modules/rds/main.tf | 7 ++++--- infrastructure/modules/rds/variables.tf | 6 ++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 11e589ef..27946527 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -340,6 +340,7 @@ No resources. | [maintenance\_window](#input\_maintenance\_window) | Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00') | `string` | `"Sun:00:00-Sun:03:00"` | no | | [major\_engine\_version](#input\_major\_engine\_version) | Major engine version for the option group (e.g. '19' for Oracle 19c) | `string` | n/a | yes | | [manage\_master\_user\_password](#input\_manage\_master\_user\_password) | When true, RDS manages the master password in Secrets Manager. When false, password\_wo must be provided | `bool` | `false` | no | +| [master\_user\_secret\_kms\_key\_id](#input\_master\_user\_secret\_kms\_key\_id) | The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. If not specified, the default KMS key for your Amazon Web Services account is used | `string` | `null` | no | | [max\_allocated\_storage](#input\_max\_allocated\_storage) | Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling | `number` | `0` | no | | [monitoring\_interval](#input\_monitoring\_interval) | Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable | `number` | `5` | no | | [multi\_az](#input\_multi\_az) | Specifies if the RDS instance is Multi-AZ | `bool` | `false` | no | diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 30efc083..109d8d03 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -43,9 +43,10 @@ module "rds" { port = var.port # Credentials - manage_master_user_password = var.manage_master_user_password - password_wo = var.password_wo - password_wo_version = var.password_wo_version + manage_master_user_password = var.manage_master_user_password + master_user_secret_kms_key_id = var.master_user_secret_kms_key_id + password_wo = var.password_wo + password_wo_version = var.password_wo_version # Networking publicly_accessible = false diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index 7649da94..86a582ac 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -116,6 +116,12 @@ variable "manage_master_user_password" { default = false } +variable "master_user_secret_kms_key_id" { + description = "The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. If not specified, the default KMS key for your Amazon Web Services account is used" + type = string + default = null +} + # ---------------------------------------------------------------------------- # Database # ---------------------------------------------------------------------------- From d7b7a4a0b9ae04dd3750dffef66e606747fa8a20 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 27 Jul 2026 13:45:46 +0100 Subject: [PATCH 23/26] feat(rds): add CloudWatch log group support and input validations --- infrastructure/modules/rds/README.md | 17 +++++-- infrastructure/modules/rds/main.tf | 8 ++++ infrastructure/modules/rds/outputs.tf | 7 +++ infrastructure/modules/rds/validations.tf | 31 +++++++++++++ infrastructure/modules/rds/variables.tf | 56 ++++++++++++++++++++++- 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 infrastructure/modules/rds/validations.tf diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 27946527..ded4b9ad 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -158,7 +158,7 @@ This example shows the recommended pattern for production use: integrating with ```hcl # Create a security group using the NHS security-group wrapper module module "rds_security_group" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z"" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z" service = "bcss" project = "screening" @@ -289,7 +289,9 @@ When migrating from `../../modules/rds` in the bcss repo to this shared module, ## Providers -No providers. +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -300,7 +302,9 @@ No providers. ## Resources -No resources. +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | ## Inputs @@ -315,7 +319,10 @@ No resources. | [backup\_retention\_period](#input\_backup\_retention\_period) | Number of days to retain automated backups. Must be between 0 and 35 | `number` | `7` | no | | [backup\_window](#input\_backup\_window) | Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance\_window | `string` | `"23:00-23:30"` | no | | [character\_set\_name](#input\_character\_set\_name) | Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot | `string` | `null` | no | +| [cloudwatch\_log\_group\_kms\_key\_id](#input\_cloudwatch\_log\_group\_kms\_key\_id) | ARN of the customer-managed KMS key to encrypt CloudWatch log groups. Required when create\_cloudwatch\_log\_group is true and enabled\_cloudwatch\_logs\_exports is non-empty. AWS-managed keys are not acceptable per platform policy. Not used when create\_cloudwatch\_log\_group is false. | `string` | `null` | no | +| [cloudwatch\_log\_group\_retention\_in\_days](#input\_cloudwatch\_log\_group\_retention\_in\_days) | Number of days to retain CloudWatch log groups created for RDS log exports. | `number` | `30` | 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 | +| [create\_cloudwatch\_log\_group](#input\_create\_cloudwatch\_log\_group) | When true (default), the module creates one CloudWatch log group per log type exported.
Log groups are named: /aws/rds/instance//

Set to false when the log groups are pre-created at consumer level — for example,
when you need a specific resource policy, cross-account sharing, or to have the
log group managed by a central logging stack. The log groups must exist before the
RDS instance is created and must follow the naming convention above.

When false, cloudwatch\_log\_group\_kms\_key\_id is not required by this module
(encryption is the caller's responsibility on the pre-existing log groups). | `bool` | `true` | 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 | | [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no | @@ -323,6 +330,7 @@ No resources. | [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 | +| [enabled\_cloudwatch\_logs\_exports](#input\_enabled\_cloudwatch\_logs\_exports) | List of log types to export to CloudWatch Logs. Valid values by engine: postgres = ['postgresql', 'upgrade'], mysql = ['audit', 'error', 'general', 'slowquery'], oracle = ['alert', 'audit', 'listener', 'oemagent', 'trace'], sqlserver = ['agent', 'error']. Leave empty to disable. | `list(string)` | `[]` | no | | [engine](#input\_engine) | The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql') | `string` | n/a | yes | | [engine\_version](#input\_engine\_version) | The engine version to use | `string` | n/a | yes | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | @@ -340,7 +348,7 @@ No resources. | [maintenance\_window](#input\_maintenance\_window) | Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00') | `string` | `"Sun:00:00-Sun:03:00"` | no | | [major\_engine\_version](#input\_major\_engine\_version) | Major engine version for the option group (e.g. '19' for Oracle 19c) | `string` | n/a | yes | | [manage\_master\_user\_password](#input\_manage\_master\_user\_password) | When true, RDS manages the master password in Secrets Manager. When false, password\_wo must be provided | `bool` | `false` | no | -| [master\_user\_secret\_kms\_key\_id](#input\_master\_user\_secret\_kms\_key\_id) | The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. If not specified, the default KMS key for your Amazon Web Services account is used | `string` | `null` | no | +| [master\_user\_secret\_kms\_key\_id](#input\_master\_user\_secret\_kms\_key\_id) | The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. Required when manage\_master\_user\_password is true. AWS-managed keys are not acceptable per platform policy. | `string` | `null` | no | | [max\_allocated\_storage](#input\_max\_allocated\_storage) | Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling | `number` | `0` | no | | [monitoring\_interval](#input\_monitoring\_interval) | Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable | `number` | `5` | no | | [multi\_az](#input\_multi\_az) | Specifies if the RDS instance is Multi-AZ | `bool` | `false` | no | @@ -379,6 +387,7 @@ No resources. | Name | Description | | ---- | ----------- | +| [cloudwatch\_log\_group\_arns](#output\_cloudwatch\_log\_group\_arns) | Map of CloudWatch log group names to ARNs, keyed by log type. Empty when enabled\_cloudwatch\_logs\_exports is not set. | | [db\_option\_group\_id](#output\_db\_option\_group\_id) | ID of the DB option group | | [db\_parameter\_group\_id](#output\_db\_parameter\_group\_id) | ID of the DB parameter group | | [db\_subnet\_group\_id](#output\_db\_subnet\_group\_id) | Name/ID of the DB subnet group | diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 109d8d03..fd44ebc6 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -9,6 +9,8 @@ # - copy_tags_to_snapshot = true (snapshots must carry the same tags) # - auto_minor_version_upgrade = false (teams keep instances in sync with prod) # - create_db_subnet_group = true (subnet group always managed by this module) +# +# Cross-variable input constraints are enforced in validations.tf. # ---------------------------------------------------------------------------- module "rds" { @@ -67,6 +69,12 @@ module "rds" { monitoring_interval = var.monitoring_interval create_monitoring_role = module.this.enabled && var.monitoring_interval > 0 + # CloudWatch log exports + enabled_cloudwatch_logs_exports = var.enabled_cloudwatch_logs_exports + create_cloudwatch_log_group = var.create_cloudwatch_log_group + cloudwatch_log_group_retention_in_days = var.cloudwatch_log_group_retention_in_days + cloudwatch_log_group_kms_key_id = var.cloudwatch_log_group_kms_key_id + # Availability and backup multi_az = var.multi_az backup_retention_period = var.backup_retention_period diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf index 7d642c75..680c075d 100644 --- a/infrastructure/modules/rds/outputs.tf +++ b/infrastructure/modules/rds/outputs.tf @@ -68,3 +68,10 @@ output "enhanced_monitoring_iam_role_arn" { description = "ARN of the Enhanced Monitoring IAM role. Empty when monitoring_interval is 0" value = module.rds.enhanced_monitoring_iam_role_arn } + +# CloudWatch log groups + +output "cloudwatch_log_group_arns" { + description = "Map of CloudWatch log group names to ARNs, keyed by log type. Empty when enabled_cloudwatch_logs_exports is not set." + value = try(module.rds.db_instance_cloudwatch_log_groups, {}) +} diff --git a/infrastructure/modules/rds/validations.tf b/infrastructure/modules/rds/validations.tf new file mode 100644 index 00000000..914780c8 --- /dev/null +++ b/infrastructure/modules/rds/validations.tf @@ -0,0 +1,31 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * storage_type 'io1' or 'io2' requires iops to be set +# * manage_master_user_password = true requires master_user_secret_kms_key_id +# * module-managed log groups require cloudwatch_log_group_kms_key_id (when create_cloudwatch_log_group = true) +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = !(contains(["io1", "io2"], coalesce(var.storage_type, "")) && var.iops == null) + error_message = "iops must be set when storage_type is 'io1' or 'io2'." + } + + precondition { + condition = !var.manage_master_user_password || var.master_user_secret_kms_key_id != null + error_message = "master_user_secret_kms_key_id must be set when manage_master_user_password is true. AWS-managed keys are not acceptable per platform policy." + } + + precondition { + condition = !(var.create_cloudwatch_log_group && length(var.enabled_cloudwatch_logs_exports) > 0 && var.cloudwatch_log_group_kms_key_id == null) + error_message = "cloudwatch_log_group_kms_key_id must be set when the module creates CloudWatch log groups (create_cloudwatch_log_group = true). AWS-managed keys are not acceptable per platform policy." + } + } +} diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index 86a582ac..2735002e 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -117,7 +117,7 @@ variable "manage_master_user_password" { } variable "master_user_secret_kms_key_id" { - description = "The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. If not specified, the default KMS key for your Amazon Web Services account is used" + description = "The key ARN, key ID, alias ARN or alias name for the KMS key to encrypt the master user password secret in Secrets Manager. Required when manage_master_user_password is true. AWS-managed keys are not acceptable per platform policy." type = string default = null } @@ -205,18 +205,33 @@ variable "backup_retention_period" { description = "Number of days to retain automated backups. Must be between 0 and 35" type = number default = 7 + + validation { + condition = var.backup_retention_period >= 0 && var.backup_retention_period <= 35 + error_message = "backup_retention_period must be between 0 and 35." + } } variable "backup_window" { description = "Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance_window" type = string default = "23:00-23:30" + + validation { + condition = can(regex("^[0-9]{2}:[0-9]{2}-[0-9]{2}:[0-9]{2}$", var.backup_window)) + error_message = "backup_window must be in HH:MM-HH:MM format (e.g. '23:00-23:30')." + } } variable "maintenance_window" { description = "Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00')" type = string default = "Sun:00:00-Sun:03:00" + + validation { + condition = can(regex("^(Mon|Tue|Wed|Thu|Fri|Sat|Sun):[0-9]{2}:[0-9]{2}-(Mon|Tue|Wed|Thu|Fri|Sat|Sun):[0-9]{2}:[0-9]{2}$", var.maintenance_window)) + error_message = "maintenance_window must be in Ddd:HH:MM-Ddd:HH:MM format (e.g. 'Sun:00:00-Sun:03:00')." + } } variable "skip_final_snapshot" { @@ -306,3 +321,42 @@ variable "timeouts" { }) default = null } + +# ---------------------------------------------------------------------------- +# CloudWatch log exports +# ---------------------------------------------------------------------------- + +variable "enabled_cloudwatch_logs_exports" { + description = "List of log types to export to CloudWatch Logs. Valid values by engine: postgres = ['postgresql', 'upgrade'], mysql = ['audit', 'error', 'general', 'slowquery'], oracle = ['alert', 'audit', 'listener', 'oemagent', 'trace'], sqlserver = ['agent', 'error']. Leave empty to disable." + type = list(string) + default = [] +} + +variable "create_cloudwatch_log_group" { + description = <<-EOT + When true (default), the module creates one CloudWatch log group per log type exported. + Log groups are named: /aws/rds/instance// + + Set to false when the log groups are pre-created at consumer level — for example, + when you need a specific resource policy, cross-account sharing, or to have the + log group managed by a central logging stack. The log groups must exist before the + RDS instance is created and must follow the naming convention above. + + When false, cloudwatch_log_group_kms_key_id is not required by this module + (encryption is the caller's responsibility on the pre-existing log groups). + EOT + type = bool + default = true +} + +variable "cloudwatch_log_group_retention_in_days" { + description = "Number of days to retain CloudWatch log groups created for RDS log exports." + type = number + default = 30 +} + +variable "cloudwatch_log_group_kms_key_id" { + description = "ARN of the customer-managed KMS key to encrypt CloudWatch log groups. Required when create_cloudwatch_log_group is true and enabled_cloudwatch_logs_exports is non-empty. AWS-managed keys are not acceptable per platform policy. Not used when create_cloudwatch_log_group is false." + type = string + default = null +} From 3d6e882acd3e86498a9f6addaafa2dd00a498569 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 28 Jul 2026 13:25:20 +0100 Subject: [PATCH 24/26] fix(ecs, efs): prevent default security group creation by requiring caller-supplied security groups --- infrastructure/modules/ecs-service/README.md | 1 + infrastructure/modules/ecs-service/main.tf | 1 + infrastructure/modules/efs/README.md | 2 +- infrastructure/modules/efs/main.tf | 3 ++- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index afc52e7c..98627006 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -10,6 +10,7 @@ the shared `context.tf` for naming and tagging. |Control|How it is enforced| |---|---| |No public IP|`assign_public_ip` defaults to `false`; tasks run on private subnets| +|Security groups|Caller must provide security group IDs via `security_group_ids`; `create_security_group = false` prevents default SG creation| |ECS-managed tags|`enable_ecs_managed_tags` defaults to `true`; AWS propagates resource tags to tasks| |Tag propagation|`propagate_tags` defaults to `TASK_DEFINITION` so tasks inherit service tags| |Creation gate|`create = module.this.enabled`; no resources are created when the module is disabled| diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index b3058d23..09518c98 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -36,6 +36,7 @@ module "ecs_service" { cpu = var.cpu create_iam_role = var.create_iam_role create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = false create_service = var.create_service create_task_definition = var.create_task_definition create_task_exec_iam_role = var.create_task_exec_iam_role diff --git a/infrastructure/modules/efs/README.md b/infrastructure/modules/efs/README.md index f0eaa86e..a89581d0 100644 --- a/infrastructure/modules/efs/README.md +++ b/infrastructure/modules/efs/README.md @@ -11,7 +11,7 @@ the shared `context.tf` for naming and tagging. |---|---| |Encryption at rest|KMS encryption is mandatory; `var.kms_key_arn` is required| |Encryption in transit|TLS is always required; non-secure transport is denied by default| -|Security groups|Mount targets require explicit security groups from the caller; no defaults| +|Security groups|Mount targets require explicit security groups from the caller; `create_security_group = false` prevents default SG creation| |Destructive operations|Delete operations are denied by default; must be explicitly allowed via policy| |Tagging|All EFS resources tagged via `module.this.tags`| |Creation gate|Resource creation gated by `module.this.enabled`| diff --git a/infrastructure/modules/efs/main.tf b/infrastructure/modules/efs/main.tf index 42282774..82be809e 100644 --- a/infrastructure/modules/efs/main.tf +++ b/infrastructure/modules/efs/main.tf @@ -48,7 +48,8 @@ module "efs" { # ---------------------------------------------------------------- # Mount targets: caller must provide security group(s) and subnets. # ---------------------------------------------------------------- - mount_targets = var.mount_targets + mount_targets = var.mount_targets + create_security_group = false # ---------------------------------------------------------------- # Backup policy (optional). From 771bfe11ad6ef725b75d8649312fd17b621e0a18 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 28 Jul 2026 15:53:54 +0100 Subject: [PATCH 25/26] fix(alb): add count condition to validation resource for module enablement --- infrastructure/modules/alb/validations.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/modules/alb/validations.tf b/infrastructure/modules/alb/validations.tf index f3bc72e3..80d6481e 100644 --- a/infrastructure/modules/alb/validations.tf +++ b/infrastructure/modules/alb/validations.tf @@ -7,6 +7,8 @@ ################################################################ resource "terraform_data" "validation" { + count = module.this.enabled ? 1 : 0 + lifecycle { precondition { condition = var.internal || var.access_logs != null From 5248a5d4c75f468d5a925e8c72dd9b94d2d8ae29 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 28 Jul 2026 16:59:02 +0100 Subject: [PATCH 26/26] fix(alb): enhance ALB module with additional configuration options and validation checks --- infrastructure/modules/alb/README.md | 8 ++++---- infrastructure/modules/alb/locals.tf | 9 +++++++++ infrastructure/modules/alb/main.tf | 10 +++++----- infrastructure/modules/alb/validations.tf | 6 +++--- infrastructure/modules/alb/variables.tf | 24 +++++++++++------------ 5 files changed, 33 insertions(+), 24 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index 402db778..238a6775 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -632,10 +632,10 @@ For **NLB (network load balancer):** | [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 | -| [desync\_mitigation\_mode](#input\_desync\_mitigation\_mode) | HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. Only valid for ALB. 'defensive' is the AWS default and recommended for security. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode | `string` | `"defensive"` | no | +| [desync\_mitigation\_mode](#input\_desync\_mitigation\_mode) | HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. ALB only. When null, this module applies 'defensive' automatically for ALB and passes null for NLB. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode | `string` | `null` | no | | [enable\_cross\_zone\_load\_balancing](#input\_enable\_cross\_zone\_load\_balancing) | When true, cross-zone load balancing distributes traffic across all registered targets in all enabled AZs. Defaults to true. Incurs additional data transfer costs but provides better availability. | `bool` | `true` | no | | [enable\_deletion\_protection](#input\_enable\_deletion\_protection) | When true, deletion protection is enabled on the load balancer. Set to false for non-production environments where the load balancer needs to be freely destroyed. | `bool` | `true` | no | -| [enable\_http2](#input\_enable\_http2) | When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. Only valid for ALB. Defaults to true. | `bool` | `true` | no | +| [enable\_http2](#input\_enable\_http2) | When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. ALB only. When null, this module applies true automatically for ALB and passes null for NLB. | `bool` | `null` | no | | [enable\_http\_https\_redirect](#input\_enable\_http\_https\_redirect) | When true, automatically adds a port-80 HTTP-to-HTTPS (301) redirect listener. Only applies when load\_balancer\_type is 'application'. Set to false if you are defining your own HTTP listener or the ALB is not serving HTTPS traffic. | `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 | @@ -651,7 +651,7 @@ For **NLB (network load balancer):** | [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 | -| [preserve\_host\_header](#input\_preserve\_host\_header) | When true, ALB preserves the original Host header from the client request instead of rewriting it. Only valid for ALB. Defaults to false. | `bool` | `false` | no | +| [preserve\_host\_header](#input\_preserve\_host\_header) | When true, ALB preserves the original Host header from the client request instead of rewriting it. ALB only. When null, this module applies false automatically for ALB and passes null for NLB. | `bool` | `null` | 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 | @@ -669,7 +669,7 @@ For **NLB (network load balancer):** | [vpc\_id](#input\_vpc\_id) | ID of the VPC in which the load balancer will be created. | `string` | n/a | yes | | [web\_acl\_arn](#input\_web\_acl\_arn) | ARN of a WAFv2 Web ACL to associate with the load balancer. Only valid for ALB. When null, no WAF association 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 | -| [xff\_header\_processing\_mode](#input\_xff\_header\_processing\_mode) | How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. 'append' is AWS default. Only valid for ALB. | `string` | `"append"` | no | +| [xff\_header\_processing\_mode](#input\_xff\_header\_processing\_mode) | How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. ALB only. When null, this module applies 'append' automatically for ALB and passes null for NLB. | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/alb/locals.tf b/infrastructure/modules/alb/locals.tf index 95914892..e762afe3 100644 --- a/infrastructure/modules/alb/locals.tf +++ b/infrastructure/modules/alb/locals.tf @@ -1,4 +1,13 @@ locals { + is_alb = var.load_balancer_type == "application" + + # ALB-only derived defaults. NLB keeps null for these upstream inputs. + effective_drop_invalid_header_fields = local.is_alb ? true : null + effective_desync_mitigation_mode = local.is_alb ? coalesce(var.desync_mitigation_mode, "defensive") : null + effective_enable_http2 = local.is_alb ? coalesce(var.enable_http2, true) : null + effective_xff_header_processing_mode = local.is_alb ? coalesce(var.xff_header_processing_mode, "append") : null + effective_preserve_host_header = local.is_alb ? coalesce(var.preserve_host_header, false) : null + # Inject an HTTP → HTTPS redirect listener on port 80 when enabled (ALB only). # Callers can override by providing their own "http-redirect" key in var.listeners. http_redirect_listener = var.enable_http_https_redirect && var.load_balancer_type == "application" ? { diff --git a/infrastructure/modules/alb/main.tf b/infrastructure/modules/alb/main.tf index c9a6669e..e9470e4a 100644 --- a/infrastructure/modules/alb/main.tf +++ b/infrastructure/modules/alb/main.tf @@ -40,7 +40,7 @@ module "alb" { # the upstream module does not error. # ---------------------------------------------------------------- enable_deletion_protection = var.enable_deletion_protection - drop_invalid_header_fields = var.load_balancer_type == "application" ? true : null + drop_invalid_header_fields = local.effective_drop_invalid_header_fields # ---------------------------------------------------------------- # Security groups — REQUIRED caller-supplied list. @@ -59,11 +59,11 @@ module "alb" { # Preserve host header: maintains original Host header from client (ALB only). # Cross-zone load balancing: distribute traffic across AZs. # ---------------------------------------------------------------- - desync_mitigation_mode = var.load_balancer_type == "application" ? var.desync_mitigation_mode : null - enable_http2 = var.load_balancer_type == "application" ? var.enable_http2 : null - xff_header_processing_mode = var.load_balancer_type == "application" ? var.xff_header_processing_mode : null + desync_mitigation_mode = local.effective_desync_mitigation_mode + enable_http2 = local.effective_enable_http2 + xff_header_processing_mode = local.effective_xff_header_processing_mode idle_timeout = var.idle_timeout - preserve_host_header = var.load_balancer_type == "application" ? var.preserve_host_header : null + preserve_host_header = local.effective_preserve_host_header enable_cross_zone_load_balancing = var.enable_cross_zone_load_balancing # ---------------------------------------------------------------- diff --git a/infrastructure/modules/alb/validations.tf b/infrastructure/modules/alb/validations.tf index 80d6481e..36c2d576 100644 --- a/infrastructure/modules/alb/validations.tf +++ b/infrastructure/modules/alb/validations.tf @@ -21,7 +21,7 @@ resource "terraform_data" "validation" { } precondition { - condition = !var.enable_http2 || var.load_balancer_type == "application" + condition = var.enable_http2 == null || !var.enable_http2 || var.load_balancer_type == "application" error_message = "var.enable_http2 is only valid for ALB (load_balancer_type = 'application'). Set enable_http2 = false for NLB or change load_balancer_type to 'application'." } @@ -31,12 +31,12 @@ resource "terraform_data" "validation" { } precondition { - condition = var.preserve_host_header == false || var.load_balancer_type == "application" + condition = var.preserve_host_header == null || var.preserve_host_header == false || var.load_balancer_type == "application" error_message = "var.preserve_host_header is only valid for ALB (load_balancer_type = 'application'). Set preserve_host_header = false for NLB or change load_balancer_type to 'application'." } precondition { - condition = var.xff_header_processing_mode == "append" || var.load_balancer_type == "application" + condition = var.xff_header_processing_mode == null || var.xff_header_processing_mode == "append" || var.load_balancer_type == "application" error_message = "var.xff_header_processing_mode is only valid for ALB (load_balancer_type = 'application'). Use default (append) or change load_balancer_type to 'application'." } } diff --git a/infrastructure/modules/alb/variables.tf b/infrastructure/modules/alb/variables.tf index 7afb7c00..b90242bc 100644 --- a/infrastructure/modules/alb/variables.tf +++ b/infrastructure/modules/alb/variables.tf @@ -99,12 +99,12 @@ variable "enable_http_https_redirect" { variable "desync_mitigation_mode" { type = string - default = "defensive" - description = "HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. Only valid for ALB. 'defensive' is the AWS default and recommended for security. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode" + default = null + description = "HTTP request desync mitigation mode. Valid values: 'off', 'defensive', 'strictest', 'monitor'. ALB only. When null, this module applies 'defensive' automatically for ALB and passes null for NLB. See https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#desync-mitigation-mode" validation { - condition = (var.load_balancer_type != "application" && var.desync_mitigation_mode == null) || (var.load_balancer_type == "application" && contains(["off", "defensive", "strictest", "monitor"], var.desync_mitigation_mode)) - error_message = "desync_mitigation_mode must be one of: off, defensive, strictest, monitor (ALB only). For NLB, omit this variable or set to null." + condition = var.desync_mitigation_mode == null || contains(["off", "defensive", "strictest", "monitor"], var.desync_mitigation_mode) + error_message = "desync_mitigation_mode must be one of: off, defensive, strictest, monitor, or null." } } @@ -121,25 +121,25 @@ variable "idle_timeout" { variable "preserve_host_header" { type = bool - default = false - description = "When true, ALB preserves the original Host header from the client request instead of rewriting it. Only valid for ALB. Defaults to false." + default = null + description = "When true, ALB preserves the original Host header from the client request instead of rewriting it. ALB only. When null, this module applies false automatically for ALB and passes null for NLB." } variable "xff_header_processing_mode" { type = string - default = "append" - description = "How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. 'append' is AWS default. Only valid for ALB." + default = null + description = "How the ALB handles X-Forwarded-For headers. Valid values: 'append', 'replace', 'remove'. ALB only. When null, this module applies 'append' automatically for ALB and passes null for NLB." validation { - condition = contains(["append", "replace", "remove"], var.xff_header_processing_mode) - error_message = "xff_header_processing_mode must be one of: append, replace, remove." + condition = var.xff_header_processing_mode == null || contains(["append", "replace", "remove"], var.xff_header_processing_mode) + error_message = "xff_header_processing_mode must be one of: append, replace, remove, or null." } } variable "enable_http2" { type = bool - default = true - description = "When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. Only valid for ALB. Defaults to true." + default = null + description = "When true, HTTP/2 is enabled on the ALB. Improves connection efficiency. ALB only. When null, this module applies true automatically for ALB and passes null for NLB." } variable "enable_cross_zone_load_balancing" {