From fe0abdddff843129c0345f66df13a2fc83f3960f Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 12 Jun 2026 14:10:07 +0100 Subject: [PATCH 1/8] Created inital RDS module --- infrastructure/modules/rds/README.md | 151 ++++++++++ infrastructure/modules/rds/context.tf | 374 ++++++++++++++++++++++++ infrastructure/modules/rds/main.tf | 148 ++++++++++ infrastructure/modules/rds/outputs.tf | 82 ++++++ infrastructure/modules/rds/variables.tf | 282 ++++++++++++++++++ infrastructure/modules/rds/versions.tf | 10 + 6 files changed, 1047 insertions(+) create mode 100644 infrastructure/modules/rds/README.md create mode 100644 infrastructure/modules/rds/context.tf create mode 100644 infrastructure/modules/rds/main.tf create mode 100644 infrastructure/modules/rds/outputs.tf create mode 100644 infrastructure/modules/rds/variables.tf create mode 100644 infrastructure/modules/rds/versions.tf diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md new file mode 100644 index 00000000..73ca0294 --- /dev/null +++ b/infrastructure/modules/rds/README.md @@ -0,0 +1,151 @@ +# RDS Module + +Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). + +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. + +## Fixed controls + +These values are always enforced and cannot be overridden by callers. + +| Control | Value | Reason | +|---------|-------|--------| +| `publicly_accessible` | `false` | Databases must never be internet-facing | +| `storage_encrypted` | `true` | Encryption at rest is mandatory | +| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | +| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | +| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | + +## Usage + +### Oracle with a fresh database + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # context + service = var.service + environment = var.environment + workspace = terraform.workspace + + # identity + identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + + # engine + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + # sizing + instance_class = "db.m5.large" + allocated_storage = 100 + storage_type = "gp3" + + # database + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # credentials (write-only — not stored in state) + password_wo = var.rds_master_password + password_wo_version = 1 + + # networking + subnet_ids = data.aws_subnets.private.ids + vpc_id = data.aws_vpc.selected.id + pi_port = 1529 + pi_cidr_block = ["10.0.0.0/8"] + + # options (Oracle S3 integration and timezone) + options = [ + { + option_name = "S3_INTEGRATION" + version = "1.0" + port = 0 + vpc_security_group_memberships = [] + option_settings = [] + }, + { + option_name = "Timezone" + port = 0 + vpc_security_group_memberships = [] + option_settings = [ + { name = "TIME_ZONE", value = "Europe/London" } + ] + } + ] + + # parameters + parameters = [ + { name = "_add_col_optim_enabled", value = "TRUE", apply_method = "immediate" } + ] + + # availability and backup + multi_az = var.environment == "prod" ? true : false + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" ? true : false +} +``` + +### Restore from snapshot + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... (same as above) + + # When restoring from a snapshot, character_set_name must be null + character_set_name = null + snapshot_identifier = "rds:my-db-2024-01-01-06-00" +} +``` + +### RDS-managed password (no password in Terraform state) + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... + + manage_master_user_password = true + # password_wo is not required when manage_master_user_password = true +} +``` + +The master password ARN is exposed via the `master_user_secret_arn` output. + +## Outputs + +| Name | Description | +|------|-------------| +| `instance_address` | Hostname of the RDS instance (without port) | +| `instance_port` | Port number | +| `instance_endpoint` | Connection endpoint in `host:port` format | +| `instance_id` | RDS instance identifier | +| `instance_arn` | ARN of the RDS instance | +| `instance_resource_id` | RDS resource ID (used for IAM authentication) | +| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | +| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | +| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | +| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | +| `db_subnet_group_id` | DB subnet group name/ID | +| `db_parameter_group_id` | DB parameter group ID | +| `db_option_group_id` | DB option group ID | +| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | + +## Migration from the local bcss `rds` module + +When migrating from `../../modules/rds` in the bcss repo to this shared module, note: + +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. +3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). +4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. +5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf new file mode 100644 index 00000000..39d9b945 --- /dev/null +++ b/infrastructure/modules/rds/context.tf @@ -0,0 +1,374 @@ +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "../tags" + + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to this module path." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf new file mode 100644 index 00000000..d4af04c9 --- /dev/null +++ b/infrastructure/modules/rds/main.tf @@ -0,0 +1,148 @@ +data "aws_vpc" "selected" { + id = var.vpc_id +} + +locals { + create_security_group = length(var.vpc_security_group_ids) == 0 + effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] +} + +# ---------------------------------------------------------------------------- +# Security group for the RDS instance. +# +# Only created when vpc_security_group_ids is not provided. The security group +# allows inbound traffic on the DB port (and optionally the Performance Insights +# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts +# outbound traffic to HTTPS only. +# ---------------------------------------------------------------------------- + +# tflint-ignore: terraform_required_providers +resource "aws_security_group" "this" { + # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below + count = local.create_security_group ? 1 : 0 + + name_prefix = "${module.this.id}-rds-" + description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" + vpc_id = var.vpc_id + + ingress { + description = "DB port from VPC" + from_port = var.port + to_port = var.port + protocol = "tcp" + cidr_blocks = local.effective_ingress_cidr_blocks + } + + dynamic "ingress" { + for_each = var.pi_port != null ? [1] : [] + content { + description = "Performance Insights agent port" + from_port = var.pi_port + to_port = var.pi_port + protocol = "tcp" + cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks + } + } + + egress { + description = "HTTPS egress for AWS service communication" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) + + lifecycle { + create_before_destroy = true + } +} + +# ---------------------------------------------------------------------------- +# RDS instance +# +# Wraps terraform-aws-modules/rds/aws v7.2.0. +# +# Fixed controls (not exposed as variables): +# - publicly_accessible = false (databases must never be internet-facing) +# - storage_encrypted = true (encryption at rest is mandatory) +# - 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) +# ---------------------------------------------------------------------------- +module "rds" { + source = "terraform-aws-modules/rds/aws" + version = "7.2.0" + + identifier = var.identifier + + # Engine + engine = var.engine + engine_version = var.engine_version + license_model = var.license_model + character_set_name = var.character_set_name + + # Instance sizing + instance_class = var.instance_class + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + storage_type = var.storage_type + iops = var.iops + storage_encrypted = true + kms_key_id = var.kms_key_id + + # Database + db_name = var.db_name + username = var.username + 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 + + # Networking + publicly_accessible = false + vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + + # Subnet group (always managed by this module) + create_db_subnet_group = true + subnet_ids = var.subnet_ids + + # Parameter group + family = var.family + parameters = var.parameters + + # Option group + major_engine_version = var.major_engine_version + options = var.options + + # Monitoring + monitoring_interval = var.monitoring_interval + create_monitoring_role = var.monitoring_interval > 0 + + # Availability and backup + multi_az = var.multi_az + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + skip_final_snapshot = var.skip_final_snapshot + snapshot_identifier = var.snapshot_identifier + apply_immediately = var.apply_immediately + copy_tags_to_snapshot = true + auto_minor_version_upgrade = false + + # Performance Insights + performance_insights_enabled = var.performance_insights_enabled + performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null + performance_insights_kms_key_id = var.performance_insights_kms_key_id + + # Lifecycle + deletion_protection = var.deletion_protection + + timeouts = var.timeouts + + tags = module.this.tags +} diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf new file mode 100644 index 00000000..60bf8117 --- /dev/null +++ b/infrastructure/modules/rds/outputs.tf @@ -0,0 +1,82 @@ +# Instance connection details + +output "instance_address" { + description = "Hostname of the RDS instance (without port)" + value = module.rds.db_instance_address +} + +output "instance_port" { + description = "Port on which the RDS instance accepts connections" + value = module.rds.db_instance_port +} + +output "instance_endpoint" { + description = "Connection endpoint for the RDS instance in host:port format" + value = module.rds.db_instance_endpoint +} + +output "instance_id" { + description = "Identifier of the RDS instance" + value = module.rds.db_instance_identifier +} + +output "instance_arn" { + description = "ARN of the RDS instance" + value = module.rds.db_instance_arn +} + +output "instance_resource_id" { + description = "The RDS resource ID (used for IAM authentication and tagging)" + value = module.rds.db_instance_resource_id +} + +output "master_user_secret_arn" { + description = "ARN of the Secrets Manager secret for the master user password. Only populated when manage_master_user_password is true" + value = module.rds.db_instance_master_user_secret_arn +} + +# Security group + +output "rds_security_group" { + description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0] : null +} + +output "security_group_id" { + description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0].id : null +} + +# Subnet group + +output "rds_subnet_group" { + description = "The DB subnet group used by the RDS instance, with id and arn attributes" + value = { + id = module.rds.db_subnet_group_id + arn = module.rds.db_subnet_group_arn + } +} + +output "db_subnet_group_id" { + description = "Name/ID of the DB subnet group" + value = module.rds.db_subnet_group_id +} + +# Parameter and option groups + +output "db_parameter_group_id" { + description = "ID of the DB parameter group" + value = module.rds.db_parameter_group_id +} + +output "db_option_group_id" { + description = "ID of the DB option group" + value = module.rds.db_option_group_id +} + +# Monitoring + +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 +} diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf new file mode 100644 index 00000000..e5bb8191 --- /dev/null +++ b/infrastructure/modules/rds/variables.tf @@ -0,0 +1,282 @@ +# ---------------------------------------------------------------------------- +# Instance identity +# ---------------------------------------------------------------------------- + +variable "identifier" { + description = "The name of the RDS instance" + type = string +} + +# ---------------------------------------------------------------------------- +# Engine +# ---------------------------------------------------------------------------- + +variable "engine" { + description = "The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql')" + type = string +} + +variable "engine_version" { + description = "The engine version to use" + type = string +} + +variable "license_model" { + description = "License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included')" + type = string + default = null +} + +variable "character_set_name" { + description = "Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Instance sizing +# ---------------------------------------------------------------------------- + +variable "instance_class" { + description = "The instance type of the RDS instance (e.g. 'db.m5.large')" + type = string +} + +variable "allocated_storage" { + description = "The allocated storage in gibibytes" + type = number +} + +variable "max_allocated_storage" { + description = "Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling" + type = number + default = 0 +} + +variable "storage_type" { + description = "One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2'" + type = string + default = null +} + +variable "iops" { + description = "Provisioned IOPS. Required when storage_type is 'io1' or 'io2'" + type = number + default = null +} + +variable "kms_key_id" { + description = "ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Credentials +# ---------------------------------------------------------------------------- + +variable "username" { + description = "Username for the master DB user" + type = string + sensitive = true +} + +variable "password_wo" { + description = "Write-only password for the master DB user. Required when manage_master_user_password is false and snapshot_identifier is not set" + type = string + default = null + sensitive = true +} + +variable "password_wo_version" { + description = "Increment this value to trigger a password rotation when password_wo changes" + type = number + default = 1 +} + +variable "manage_master_user_password" { + description = "When true, RDS manages the master password in Secrets Manager. When false, password_wo must be provided" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Database +# ---------------------------------------------------------------------------- + +variable "db_name" { + description = "The name of the database to create. Omit to skip initial database creation" + type = string + default = null +} + +variable "port" { + description = "The port on which the DB accepts connections" + type = number +} + +# ---------------------------------------------------------------------------- +# Networking +# ---------------------------------------------------------------------------- + +variable "subnet_ids" { + description = "List of VPC subnet IDs for the DB subnet group" + type = list(string) +} + +variable "vpc_id" { + description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" + type = string +} + +variable "vpc_security_group_ids" { + description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" + type = list(string) + default = [] +} + +variable "ingress_cidr_blocks" { + description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" + type = list(string) + default = [] +} + +variable "pi_port" { + description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" + type = number + default = null +} + +variable "pi_cidr_block" { + description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + type = list(string) + default = [] +} + +# ---------------------------------------------------------------------------- +# Parameter group +# ---------------------------------------------------------------------------- + +variable "family" { + description = "DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0')" + type = string +} + +variable "parameters" { + description = "List of DB parameters to apply to the parameter group" + type = list(object({ + name = string + value = string + apply_method = optional(string) + })) + default = [] +} + +# ---------------------------------------------------------------------------- +# Option group +# ---------------------------------------------------------------------------- + +variable "major_engine_version" { + description = "Major engine version for the option group (e.g. '19' for Oracle 19c)" + type = string +} + +variable "options" { + description = "List of option group options to apply. See the community module documentation for the full object shape" + type = any + default = [] +} + +# ---------------------------------------------------------------------------- +# Availability and backup +# ---------------------------------------------------------------------------- + +variable "multi_az" { + description = "Specifies if the RDS instance is Multi-AZ" + type = bool + default = false +} + +variable "backup_retention_period" { + description = "Number of days to retain automated backups. Must be between 0 and 35" + type = number + default = 7 +} + +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" +} + +variable "maintenance_window" { + description = "Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00')" + type = string + default = "Sun:00:00-Sun:03:00" +} + +variable "skip_final_snapshot" { + description = "If true, no final snapshot is created on deletion. Should be false in production" + type = bool + default = false +} + +variable "snapshot_identifier" { + description = "Snapshot ID to restore the instance from. When set, character_set_name must be null" + type = string + default = null +} + +variable "apply_immediately" { + description = "Apply modifications immediately rather than deferring to the next maintenance window" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Monitoring and performance +# ---------------------------------------------------------------------------- + +variable "monitoring_interval" { + description = "Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable" + type = number + default = 5 +} + +variable "performance_insights_enabled" { + description = "Enable Performance Insights" + type = bool + default = true +} + +variable "performance_insights_retention_period" { + description = "Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31" + type = number + default = 7 +} + +variable "performance_insights_kms_key_id" { + description = "ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Lifecycle +# ---------------------------------------------------------------------------- + +variable "deletion_protection" { + description = "Prevents the DB instance from being deleted when true. Should be true in production" + type = bool + default = true +} + +variable "timeouts" { + description = "Terraform resource management timeouts for the DB instance" + type = object({ + create = optional(string) + update = optional(string) + delete = optional(string) + }) + default = null +} diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf new file mode 100644 index 00000000..aaa5e443 --- /dev/null +++ b/infrastructure/modules/rds/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} From 921789baf9f847f527caf93b2091ad29be5b8198 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 17 Jun 2026 17:04:38 +0100 Subject: [PATCH 2/8] Made PR improvements --- infrastructure/modules/rds/README.md | 12 ++--- infrastructure/modules/rds/main.tf | 64 +------------------------ infrastructure/modules/rds/outputs.tf | 12 ----- infrastructure/modules/rds/variables.tf | 25 +--------- 4 files changed, 6 insertions(+), 107 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 73ca0294..1c010544 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,7 +2,7 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## Fixed controls @@ -55,10 +55,8 @@ module "oracle_rds" { password_wo_version = 1 # networking - subnet_ids = data.aws_subnets.private.ids - vpc_id = data.aws_vpc.selected.id - pi_port = 1529 - pi_cidr_block = ["10.0.0.0/8"] + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] # options (Oracle S3 integration and timezone) options = [ @@ -132,8 +130,6 @@ The master password ARN is exposed via the `master_user_secret_arn` output. | `instance_arn` | ARN of the RDS instance | | `instance_resource_id` | RDS resource ID (used for IAM authentication) | | `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | -| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | | `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | | `db_subnet_group_id` | DB subnet group name/ID | | `db_parameter_group_id` | DB parameter group ID | @@ -144,7 +140,7 @@ The master password ARN is exposed via the `master_user_secret_arn` output. When migrating from `../../modules/rds` in the bcss repo to this shared module, note: -1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_subnet_group` are compatible with the local module. `rds_security_group` is no longer an output — the caller now owns the security group resource. 2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index d4af04c9..45b45d53 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -1,65 +1,3 @@ -data "aws_vpc" "selected" { - id = var.vpc_id -} - -locals { - create_security_group = length(var.vpc_security_group_ids) == 0 - effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] -} - -# ---------------------------------------------------------------------------- -# Security group for the RDS instance. -# -# Only created when vpc_security_group_ids is not provided. The security group -# allows inbound traffic on the DB port (and optionally the Performance Insights -# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts -# outbound traffic to HTTPS only. -# ---------------------------------------------------------------------------- - -# tflint-ignore: terraform_required_providers -resource "aws_security_group" "this" { - # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below - count = local.create_security_group ? 1 : 0 - - name_prefix = "${module.this.id}-rds-" - description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" - vpc_id = var.vpc_id - - ingress { - description = "DB port from VPC" - from_port = var.port - to_port = var.port - protocol = "tcp" - cidr_blocks = local.effective_ingress_cidr_blocks - } - - dynamic "ingress" { - for_each = var.pi_port != null ? [1] : [] - content { - description = "Performance Insights agent port" - from_port = var.pi_port - to_port = var.pi_port - protocol = "tcp" - cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks - } - } - - egress { - description = "HTTPS egress for AWS service communication" - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = ["::/0"] - } - - tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) - - lifecycle { - create_before_destroy = true - } -} - # ---------------------------------------------------------------------------- # RDS instance # @@ -105,7 +43,7 @@ module "rds" { # Networking publicly_accessible = false - vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) create_db_subnet_group = true diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf index 60bf8117..7d642c75 100644 --- a/infrastructure/modules/rds/outputs.tf +++ b/infrastructure/modules/rds/outputs.tf @@ -35,18 +35,6 @@ output "master_user_secret_arn" { value = module.rds.db_instance_master_user_secret_arn } -# Security group - -output "rds_security_group" { - description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0] : null -} - -output "security_group_id" { - description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0].id : null -} - # Subnet group output "rds_subnet_group" { diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index e5bb8191..d5fb751f 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -124,31 +124,8 @@ variable "subnet_ids" { type = list(string) } -variable "vpc_id" { - description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" - type = string -} - variable "vpc_security_group_ids" { - description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" - type = list(string) - default = [] -} - -variable "ingress_cidr_blocks" { - description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" - type = list(string) - default = [] -} - -variable "pi_port" { - description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" - type = number - default = null -} - -variable "pi_cidr_block" { - description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + description = "List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here" type = list(string) default = [] } From 07288efcdf4eed300d7593103059e7c83d00fbcc Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:13:20 +0100 Subject: [PATCH 3/8] feat(rds): enhance module functionality and documentation - Added support for a new RDS module path in dependabot.yaml. - Created .terraform.lock.hcl for the new RDS module. - Updated README.md to include usage examples for PostgreSQL and Oracle RDS instances, along with security group and secrets manager integration. - Modified context.tf to enable module creation based on the 'enabled' variable. - Introduced locals.tf to define rds_identifier based on provided names. - Updated main.tf to conditionally create RDS resources based on the 'enabled' variable. - Enhanced variables.tf to include custom_name variable for explicit RDS instance naming. - Updated versions.tf to require Terraform version >= 1.13 and AWS provider version >= 6.42. --- .github/dependabot.yaml | 1 + .../modules/rds/.terraform.lock.hcl | 55 ++++ infrastructure/modules/rds/README.md | 305 ++++++++++++++++-- infrastructure/modules/rds/context.tf | 2 + infrastructure/modules/rds/locals.tf | 4 + infrastructure/modules/rds/main.tf | 12 +- infrastructure/modules/rds/variables.tf | 9 +- infrastructure/modules/rds/versions.tf | 4 +- 8 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 infrastructure/modules/rds/.terraform.lock.hcl create mode 100644 infrastructure/modules/rds/locals.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 02b50d0a..13604b7d 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -58,6 +58,7 @@ updates: - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" - "infrastructure/modules/rds-users" + - "infrastructure/modules/rds" - "infrastructure/modules/s3-bucket" - "infrastructure/modules/s3" - "infrastructure/modules/secrets-manager" diff --git a/infrastructure/modules/rds/.terraform.lock.hcl b/infrastructure/modules/rds/.terraform.lock.hcl new file mode 100644 index 00000000..61bf386e --- /dev/null +++ b/infrastructure/modules/rds/.terraform.lock.hcl @@ -0,0 +1,55 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.1.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "h1:lVDv+0AjDjrLfpmaJbWqUmIw/k3/AHXLc3N4m55SNdo=", + "h1:o0s5Mk9NXMP60nlheO1r0LsDGGratFb3oL0t7bD2QnM=", + "h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 1c010544..437988f3 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,22 +2,53 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. -## Fixed controls +## What this module enforces -These values are always enforced and cannot be overridden by callers. - -| Control | Value | Reason | -|---------|-------|--------| -| `publicly_accessible` | `false` | Databases must never be internet-facing | -| `storage_encrypted` | `true` | Encryption at rest is mandatory | -| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | -| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | -| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | +|Control|Value|Reason| +|-|-|-| +|`publicly_accessible`|`false`|Databases must never be internet-facing| +|`storage_encrypted`|`true`|Encryption at rest is mandatory| +|`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| +|`auto_minor_version_upgrade`|`false`|Teams keep instances in sync with the production engine version| +|`create_db_subnet_group`|`true`|subnet group is always managed by this module| +|Creation gate|`module.this.enabled`|Prevents all managed RDS resources when disabled| ## Usage +### Minimal PostgreSQL instance + +```hcl +module "postgres_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = "dev" + stack = "database" + workspace = terraform.workspace + + engine = "postgres" + engine_version = "16" + major_engine_version = "16" + family = "postgres16" + + instance_class = "db.t4g.medium" + allocated_storage = 100 + + db_name = "screening" + username = var.rds_master_username + port = 5432 + + password_wo = var.rds_master_password + password_wo_version = 1 + + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] +} +``` + ### Oracle with a fresh database ```hcl @@ -29,8 +60,9 @@ module "oracle_rds" { environment = var.environment workspace = terraform.workspace - # identity - identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + # identity (optional) + # If omitted, the module derives a name from context labels. + custom_name = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" # engine engine = "oracle-ee" @@ -117,24 +149,121 @@ module "oracle_rds" { } ``` -The master password ARN is exposed via the `master_user_secret_arn` output. +### With security-group and secrets-manager modules -## Outputs +This example shows the recommended pattern for production use: integrating with the dedicated security-group and secrets-manager modules. -| Name | Description | -|------|-------------| -| `instance_address` | Hostname of the RDS instance (without port) | -| `instance_port` | Port number | -| `instance_endpoint` | Connection endpoint in `host:port` format | -| `instance_id` | RDS instance identifier | -| `instance_arn` | ARN of the RDS instance | -| `instance_resource_id` | RDS resource ID (used for IAM authentication) | -| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | -| `db_subnet_group_id` | DB subnet group name/ID | -| `db_parameter_group_id` | DB parameter group ID | -| `db_option_group_id` | DB option group ID | -| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | +```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"" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + + vpc_id = data.aws_vpc.private.id + description = "Security group for RDS database" + + # Allow inbound on Oracle port from application security group + ingress_rules = [ + { + from_port = 1521 + to_port = 1521 + protocol = "tcp" + security_groups = [module.app_security_group.security_group_id] + description = "Oracle from application" + } + ] +} + +# Store the master password in Secrets Manager +module "rds_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/secrets-manager?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + name = "rds-master-password" + + secret_string = var.rds_master_password + # Optionally: managed rotation, encryption key, tags, etc. +} + +# Create the RDS instance with the security group and managed password +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + stack = "database" + + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + instance_class = "db.m5.large" + allocated_storage = 500 + storage_type = "gp3" + + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # Use RDS Secrets Manager integration for password management + manage_master_user_password = true + # The password is automatically stored in Secrets Manager by RDS + # Retrieve it via module output: module.oracle_rds.master_user_secret_arn + + subnet_ids = data.aws_subnets.private.ids + # Pass the security group created above + vpc_security_group_ids = [module.rds_security_group.security_group_id] + + multi_az = var.environment == "prod" + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" + + depends_on = [module.rds_security_group] +} + +# Output the secret arn for application connection strings +output "rds_secret_arn" { + value = module.rds_secret.secret_arn + description = "arn of the RDS master password secret" +} + +output "rds_endpoint" { + value = module.oracle_rds.instance_endpoint + description = "RDS instance endpoint (host:port)" +} +``` + +The master password arn is exposed via the `master_user_secret_arn` output. + +## Conventions + +- Naming and tagging come from shared `context.tf` via `module.this`. +- Identifier resolution order is `custom_name`, then `identifier`, then `module.this.id`. +- Security groups are intentionally caller-managed. This module associates IDs passed via `vpc_security_group_ids`. +- Resource creation is gated by `module.this.enabled`. +- Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. +- Resource arn values (e.g., `instance_arn`) are exposed as output attributes. +- iam authentication and resource tagging require the instance resource ID. + +## What this module does NOT do + +- Create or manage security groups. +- Allow public internet access to the database instance. +- Disable encryption at rest. +- Enable automatic minor engine upgrades. ## Migration from the local bcss `rds` module @@ -145,3 +274,121 @@ When migrating from `../../modules/rds` in the bcss repo to this shared module, 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. 5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [rds](#module\_rds) | terraform-aws-modules/rds/aws | 7.2.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [allocated\_storage](#input\_allocated\_storage) | The allocated storage in gibibytes | `number` | n/a | yes | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [apply\_immediately](#input\_apply\_immediately) | Apply modifications immediately rather than deferring to the next maintenance window | `bool` | `false` | 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 | +| [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 | +| [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 | +| [custom\_name](#input\_custom\_name) | Optional override name for the RDS instance. Takes precedence over identifier when set. | `string` | `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 | +| [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no | +| [deletion\_protection](#input\_deletion\_protection) | Prevents the DB instance from being deleted when true. Should be true in production | `bool` | `true` | 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 | +| [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 | +| [family](#input\_family) | DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0') | `string` | n/a | yes | +| [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 | +| [identifier](#input\_identifier) | Explicit name for the RDS instance. If null, this module derives the name from context labels. | `string` | `null` | no | +| [instance\_class](#input\_instance\_class) | The instance type of the RDS instance (e.g. 'db.m5.large') | `string` | n/a | yes | +| [iops](#input\_iops) | Provisioned IOPS. Required when storage\_type is 'io1' or 'io2' | `number` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used | `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 | +| [license\_model](#input\_license\_model) | License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included') | `string` | `null` | no | +| [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 | +| [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 | +| [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 | +| [options](#input\_options) | List of option group options to apply. See the community module documentation for the full object shape | `any` | `[]` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [parameters](#input\_parameters) | List of DB parameters to apply to the parameter group |
list(object({
name = string
value = string
apply_method = optional(string)
}))
| `[]` | no | +| [password\_wo](#input\_password\_wo) | Write-only password for the master DB user. Required when manage\_master\_user\_password is false and snapshot\_identifier is not set | `string` | `null` | no | +| [password\_wo\_version](#input\_password\_wo\_version) | Increment this value to trigger a password rotation when password\_wo changes | `number` | `1` | no | +| [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights | `bool` | `true` | no | +| [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used | `string` | `null` | no | +| [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31 | `number` | `7` | no | +| [port](#input\_port) | The port on which the DB accepts connections | `number` | n/a | yes | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | +| [skip\_final\_snapshot](#input\_skip\_final\_snapshot) | If true, no final snapshot is created on deletion. Should be false in production | `bool` | `false` | no | +| [snapshot\_identifier](#input\_snapshot\_identifier) | Snapshot ID to restore the instance from. When set, character\_set\_name must be null | `string` | `null` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [storage\_type](#input\_storage\_type) | One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2' | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | List of VPC subnet IDs for the DB subnet group | `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 this module path. | `string` | `null` | no | +| [timeouts](#input\_timeouts) | Terraform resource management timeouts for the DB instance |
object({
create = optional(string)
update = optional(string)
delete = optional(string)
})
| `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [username](#input\_username) | Username for the master DB user | `string` | n/a | yes | +| [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here | `list(string)` | `[]` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [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 | +| [enhanced\_monitoring\_iam\_role\_arn](#output\_enhanced\_monitoring\_iam\_role\_arn) | ARN of the Enhanced Monitoring IAM role. Empty when monitoring\_interval is 0 | +| [instance\_address](#output\_instance\_address) | Hostname of the RDS instance (without port) | +| [instance\_arn](#output\_instance\_arn) | ARN of the RDS instance | +| [instance\_endpoint](#output\_instance\_endpoint) | Connection endpoint for the RDS instance in host:port format | +| [instance\_id](#output\_instance\_id) | Identifier of the RDS instance | +| [instance\_port](#output\_instance\_port) | Port on which the RDS instance accepts connections | +| [instance\_resource\_id](#output\_instance\_resource\_id) | The RDS resource ID (used for IAM authentication and tagging) | +| [master\_user\_secret\_arn](#output\_master\_user\_secret\_arn) | ARN of the Secrets Manager secret for the master user password. Only populated when manage\_master\_user\_password is true | +| [rds\_subnet\_group](#output\_rds\_subnet\_group) | The DB subnet group used by the RDS instance, with id and arn attributes | + + + diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf index 39d9b945..62befcb0 100644 --- a/infrastructure/modules/rds/context.tf +++ b/infrastructure/modules/rds/context.tf @@ -1,3 +1,4 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations # # ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags # All other instances of this file should be a copy of that one @@ -23,6 +24,7 @@ module "this" { source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/rds/locals.tf b/infrastructure/modules/rds/locals.tf new file mode 100644 index 00000000..0f350376 --- /dev/null +++ b/infrastructure/modules/rds/locals.tf @@ -0,0 +1,4 @@ +locals { + # Prefer explicit caller names when provided, otherwise derive from context labels. + rds_identifier = coalesce(var.custom_name, var.identifier, module.this.id) +} diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 45b45d53..32c8cfce 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -14,7 +14,12 @@ module "rds" { source = "terraform-aws-modules/rds/aws" version = "7.2.0" - identifier = var.identifier + create_db_instance = module.this.enabled + create_db_subnet_group = module.this.enabled + create_db_parameter_group = module.this.enabled + create_db_option_group = module.this.enabled + + identifier = local.rds_identifier # Engine engine = var.engine @@ -46,8 +51,7 @@ module "rds" { vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) - create_db_subnet_group = true - subnet_ids = var.subnet_ids + subnet_ids = var.subnet_ids # Parameter group family = var.family @@ -59,7 +63,7 @@ module "rds" { # Monitoring monitoring_interval = var.monitoring_interval - create_monitoring_role = var.monitoring_interval > 0 + create_monitoring_role = module.this.enabled && var.monitoring_interval > 0 # Availability and backup multi_az = var.multi_az diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index d5fb751f..83bee1b3 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -3,8 +3,15 @@ # ---------------------------------------------------------------------------- variable "identifier" { - description = "The name of the RDS instance" + description = "Explicit name for the RDS instance. If null, this module derives the name from context labels." type = string + default = null +} + +variable "custom_name" { + description = "Optional override name for the RDS instance. Takes precedence over identifier when set." + type = string + default = null } # ---------------------------------------------------------------------------- diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf index aaa5e443..cb30fe5c 100644 --- a/infrastructure/modules/rds/versions.tf +++ b/infrastructure/modules/rds/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.11.1" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28" + version = ">= 6.42" } } } From 350f90814ccda890f96398ab22282243b5323c88 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 23:18:14 +0100 Subject: [PATCH 4/8] chore: Dependabot PRs #32, #60, #90 (#91) * Bump requests in /docs/adr/assets/ADR-003/examples/python Bumps [requests](https://github.com/psf/requests) from 2.32.0 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.0...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * build(deps): bump pyjwt in /docs/adr/assets/ADR-003/examples/python Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.8.0 to 2.13.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.8.0...2.13.0) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.13.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * build(deps): bump golang.org/x/net Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.38.0. - [Commits](https://github.com/golang/net/compare/v0.23.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.38.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/adr/assets/ADR-003/examples/golang/go.mod | 4 ++-- docs/adr/assets/ADR-003/examples/golang/go.sum | 4 ++-- docs/adr/assets/ADR-003/examples/python/requirements.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/adr/assets/ADR-003/examples/golang/go.mod b/docs/adr/assets/ADR-003/examples/golang/go.mod index 0cdf5e68..5e66241a 100644 --- a/docs/adr/assets/ADR-003/examples/golang/go.mod +++ b/docs/adr/assets/ADR-003/examples/golang/go.mod @@ -1,10 +1,10 @@ module github-app-get-tokent -go 1.21.0 +go 1.23.0 require ( github.com/go-resty/resty/v2 v2.7.0 github.com/golang-jwt/jwt v3.2.2+incompatible ) -require golang.org/x/net v0.23.0 // indirect +require golang.org/x/net v0.38.0 // indirect diff --git a/docs/adr/assets/ADR-003/examples/golang/go.sum b/docs/adr/assets/ADR-003/examples/golang/go.sum index 646b27e4..cd849816 100644 --- a/docs/adr/assets/ADR-003/examples/golang/go.sum +++ b/docs/adr/assets/ADR-003/examples/golang/go.sum @@ -3,8 +3,8 @@ github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSM github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/docs/adr/assets/ADR-003/examples/python/requirements.txt b/docs/adr/assets/ADR-003/examples/python/requirements.txt index 22784e07..0081b1d6 100644 --- a/docs/adr/assets/ADR-003/examples/python/requirements.txt +++ b/docs/adr/assets/ADR-003/examples/python/requirements.txt @@ -1,2 +1,2 @@ -PyJWT==2.8.0 -requests==2.32.0 +PyJWT==2.13.0 +requests==2.33.0 From 125bb144db86ec7ddc50f3a5be0307cc0a348d97 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 00:35:01 +0100 Subject: [PATCH 5/8] ci: enhance secret scanning with new hooks for both staged changes and complete history (#92) --- .../pre-commit-hooks.instructions.md | 3 +- .github/skills/pre-commit-hooks.skill.md | 76 +++++++++++++++---- .github/workflows/stage-1-pre-commit.yml | 2 +- .pre-commit-config.yaml | 10 ++- .../user-guides/Pre_commit_hooks_reference.md | 71 ++++++++++++++--- docs/user-guides/Scan_secrets.md | 60 +++++++++++++-- 6 files changed, 188 insertions(+), 34 deletions(-) diff --git a/.github/instructions/pre-commit-hooks.instructions.md b/.github/instructions/pre-commit-hooks.instructions.md index db5d4b5d..bf207fc3 100644 --- a/.github/instructions/pre-commit-hooks.instructions.md +++ b/.github/instructions/pre-commit-hooks.instructions.md @@ -42,7 +42,8 @@ git commit -m "type(scope): description" - `detect-aws-credentials` — detects embedded secrets - `detect-private-key` — detects leaked private keys -- `scan-secrets` — scans git history for secrets +- `scan-secrets-staged-changes` — scans staged changes for secrets (runs on `git commit`) +- `scan-secrets-whole-history` — scans entire git history for secrets (runs on `pre-commit run --all-files`) - `terraform_validate` — ensures modules are syntactically valid - `regenerate-dependabot-config` — ensures Dependabot watches all modules - `no-commit-to-branch` — enforces PR workflow diff --git a/.github/skills/pre-commit-hooks.skill.md b/.github/skills/pre-commit-hooks.skill.md index 5d512f5e..e6ac26d8 100644 --- a/.github/skills/pre-commit-hooks.skill.md +++ b/.github/skills/pre-commit-hooks.skill.md @@ -509,51 +509,94 @@ vale README.md infrastructure/AGENTS.md ### Category 5: Security & Secrets -#### 5.1 `scan-secrets` — Secret Scanning via Gitleaks +#### 5.1 `scan-secrets-staged-changes` — Secret Scanning (Staged Files) -**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.) using Gitleaks. +**What it does:** Scans staged changes for embedded secrets (API keys, credentials, etc.) using Gitleaks. Runs automatically on `git commit`. **When it fails:** ```text Leaks found: 1 -File: .env.example +File: .env Secret: aws_secret_access_key = "AKIA2EXAMPLE..." ``` **Common false positives:** +- Example/placeholder credentials in `.env.example` +- Test data that looks like credentials + +**Fix:** + +##### Staged changes: Real secret detected + +```bash +# Unstage the file immediately +git reset .env + +# Remove or edit to remove the secret +rm .env # or edit to remove secrets + +# Stage clean version and commit +git add .env +git commit -m "fix: remove secret from env" +``` + +##### Staged changes: False positive detected + +```bash +# Add to .gitleaksignore +echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore + +# Re-stage and commit +git add .gitleaksignore +git commit -m "chore: ignore false positive" +``` + +--- + +#### 5.2 `scan-secrets-whole-history` — Secret Scanning (Complete History) + +**What it does:** Scans entire git history for embedded secrets using Gitleaks. Runs on `pre-commit run --all-files` or in CI/CD. + +**When it fails:** + +```text +Leaks found: 1 +File: config/old-backup.tf (in commit abc1234) +Secret: aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" +``` + +**Common false positives:** + - Example/placeholder credentials in `.env.example` - Test data that looks like credentials - Version strings mistaken for IPv4 addresses **Fix:** -#### Option 1: Real secret (CRITICAL) +##### History scan: Real secret in git history (CRITICAL) ```bash -# Remove the secret immediately +# Remove from history (destructive operation) git filter-branch --force --index-filter \ - 'git rm --cached --ignore-unmatch PATH_TO_FILE' \ + 'git rm --cached --ignore-unmatch config/old-backup.tf' \ --prune-empty --tag-name-filter cat -- --all -# Force push (warning: destructive) +# Force push to remove from remote git push origin +main + +# IMPORTANT: Regenerate/rotate any exposed credentials ``` -#### Option 2: False positive (Add to ignore list) +##### History scan: False positive in git history ```bash -# Get the fingerprint from the error # Add to .gitleaksignore echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore -``` - -**Manual run:** -```bash -gitleaks detect --verbose -gitleaks detect -i .gitleaksignore # With ignores +# Re-run to verify +pre-commit run scan-secrets-whole-history --all-files ``` --- @@ -764,7 +807,8 @@ fi | `check-english-usage` | `.md` | ❌ No | Low | Format | | `detect-aws-credentials` | All | ❌ No | **CRITICAL** | Security | | `detect-private-key` | All | ❌ No | **CRITICAL** | Security | -| `scan-secrets` | All | ❌ No | **CRITICAL** | Security | +| `scan-secrets-staged-changes` | All | ❌ No | **CRITICAL** | Security | +| `scan-secrets-whole-history` | All | ❌ No | **CRITICAL** | Security | | `conventional-commit` | Commit msg | ❌ No | Medium | Commit | | `no-commit-to-branch` | N/A | ❌ No | High | Git | diff --git a/.github/workflows/stage-1-pre-commit.yml b/.github/workflows/stage-1-pre-commit.yml index e0fd958d..668d4918 100644 --- a/.github/workflows/stage-1-pre-commit.yml +++ b/.github/workflows/stage-1-pre-commit.yml @@ -33,7 +33,7 @@ jobs: - name: "shell-and-content" hooks: >- shellcheck check-file-format check-markdown-format - check-english-usage scan-secrets + check-english-usage scan-secrets-whole-history - name: "terraform-format-lint" hooks: >- generate-terraform-providers terraform_fmt terraform_tflint diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5cc4fa4..ad7b29be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -180,8 +180,14 @@ repos: files: \.tf(vars)?$ pass_filenames: false - - id: scan-secrets - name: scan-secrets + - id: scan-secrets-staged-changes + name: scan-secrets-staged-changes + entry: bash -c 'check=staged-changes ./scripts/githooks/scan-secrets.sh' + language: system + pass_filenames: false + + - id: scan-secrets-whole-history + name: scan-secrets-whole-history entry: bash -c 'check=whole-history ./scripts/githooks/scan-secrets.sh' language: system pass_filenames: false diff --git a/docs/user-guides/Pre_commit_hooks_reference.md b/docs/user-guides/Pre_commit_hooks_reference.md index 54d48c00..55bf15ca 100644 --- a/docs/user-guides/Pre_commit_hooks_reference.md +++ b/docs/user-guides/Pre_commit_hooks_reference.md @@ -49,7 +49,7 @@ Pre-commit hooks are **automated quality checks** that run before every commit. - Prevent secrets from being committed - Save CI/CD time by fixing issues early -**This repository has 26 hooks** covering Terraform, shell scripts, file formatting, security scanning, and commit message validation. +**This repository has 27 hooks** covering Terraform, shell scripts, file formatting, security scanning, and commit message validation. --- @@ -535,15 +535,17 @@ AWS credentials detected --- -#### `scan-secrets` — Gitleaks +#### `scan-secrets-staged-changes` — Gitleaks (staged files only) -**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.). +**What it does:** Scans only the staged changes for embedded secrets (API keys, credentials, etc.). + +**When to use:** During pre-commit (automatically runs on `git commit`). Catches secrets before they're committed. **When it fails:** ```text Leaks found: 1 -File: .env.example +File: .env Secret: aws_secret_access_key = "AKIA..." ``` @@ -551,14 +553,60 @@ Secret: aws_secret_access_key = "AKIA..." If it's a **real secret** (CRITICAL): +```bash +# Unstage the file +git reset .env + +# Remove from working directory (or edit to remove the secret) +rm .env # or edit to remove secrets + +# Stage and commit the corrected version +git add .env +git commit -m "fix: remove secrets" +``` + +If it's a **false positive** (e.g., example credentials): + +```bash +# Add to .gitleaksignore +echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore + +# Re-stage and commit +git add .gitleaksignore +git commit -m "chore: ignore false positive secret scan" +``` + +--- + +#### `scan-secrets-whole-history` — Gitleaks (complete history) + +**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.). Runs on `pre-commit run --all-files` or in CI/CD. + +**When to use:** Full repository scans (CI/CD, local validation, before pushing to remote). + +**When it fails:** + +```text +Leaks found: 1 +File: config/old-backup.tf +Secret: aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" +Commit: abc1234 +``` + +**Fix:** + +If it's a **real secret** (CRITICAL — secret is in history): + ```bash # Use git filter-branch to remove from history git filter-branch --force --index-filter \ - 'git rm --cached --ignore-unmatch PATH_TO_FILE' \ + 'git rm --cached --ignore-unmatch config/old-backup.tf' \ --prune-empty --tag-name-filter cat -- --all # Force push to remove from remote git push origin +main + +# Regenerate any AWS/API credentials that were exposed ``` If it's a **false positive** (e.g., example credentials): @@ -568,13 +616,17 @@ If it's a **false positive** (e.g., example credentials): echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore # Re-run to verify -pre-commit run scan-secrets --all-files +pre-commit run scan-secrets-whole-history --all-files ``` -**Manual run:** +**Manual runs:** ```bash -gitleaks detect --verbose +# Scan staged changes only +check=staged-changes ./scripts/githooks/scan-secrets.sh + +# Scan entire history +check=whole-history ./scripts/githooks/scan-secrets.sh ``` --- @@ -768,7 +820,8 @@ git commit --no-verify -m "..." - `detect-aws-credentials` — detects leaked credentials - `detect-private-key` — detects leaked private keys -- `scan-secrets` — scans git history for secrets +- `scan-secrets-staged-changes` — scans staged changes for secrets +- `scan-secrets-whole-history` — scans entire git history for secrets If you use `--no-verify`, report the issue immediately. diff --git a/docs/user-guides/Scan_secrets.md b/docs/user-guides/Scan_secrets.md index 1e3e1e10..b8252dec 100644 --- a/docs/user-guides/Scan_secrets.md +++ b/docs/user-guides/Scan_secrets.md @@ -2,6 +2,7 @@ - [Guide: Scan secrets](#guide-scan-secrets) - [Overview](#overview) + - [Two-tier scanning: Staged changes + Complete history](#two-tier-scanning-staged-changes--complete-history) - [Key files](#key-files) - [Configuration checklist](#configuration-checklist) - [Testing](#testing) @@ -13,13 +14,54 @@ Scanning a repository for hard-coded secrets is a crucial security practice. "Ha [Gitleaks](https://github.com/gitleaks/gitleaks) is a powerful open-source tool designed to identify hard-coded secrets and other sensitive information in Git repositories. It works by scanning the commit history and the working directory for sensitive data that should not be there. +## Two-tier scanning: Staged changes + Complete history + +This repository uses two complementary secret scanning hooks to provide defense in depth: + +### `scan-secrets-staged-changes` + +- **When:** Runs automatically on `git commit` before the commit is created +- **Scope:** Scans only the files you're about to commit (staged changes) +- **Purpose:** Fast feedback loop — catches secrets before they enter the repository +- **Time:** ~1 second (very fast) + +**Fix immediately if it triggers:** + +```bash +# Unstage the file +git reset .env + +# Remove or edit to remove the secret +# Then stage and commit the corrected version +git add .env +git commit -m "fix: remove secret" +``` + +### `scan-secrets-whole-history` + +- **When:** Runs on `pre-commit run --all-files` or in CI/CD pipelines +- **Scope:** Scans entire git history (all commits) +- **Purpose:** Comprehensive audit — catches secrets that may have been committed before this hook existed +- **Time:** ~10-30 seconds (slower, but thorough) + +**Run manually:** + +```bash +# Full history scan (use before pushing to remote) +check=whole-history ./scripts/githooks/scan-secrets.sh + +# Or via pre-commit +pre-commit run scan-secrets-whole-history --all-files +``` + +**If it fails:** Secret is already in history; see [Removing sensitive data](#removing-sensitive-data) below for remediation. + ## Key files -- [`scan-secrets.sh`](../../scripts/githooks/scan-secrets.sh): A shell script that scans the codebase for hard-coded secrets +- [`scan-secrets.sh`](../../scripts/githooks/scan-secrets.sh): A shell script that scans the codebase for hard-coded secrets (supports `check=staged-changes` and `check=whole-history` modes) - [`gitleaks.toml`](../../scripts/config/gitleaks.toml): A configuration file for the secret scanner - [`.gitleaksignore`](../../.gitleaksignore): A list of fingerprints to ignore by the secret scanner - [`scan-secrets/action.yaml`](../../.github/actions/scan-secrets/action.yaml): GitHub action to run the scripts as part of the CI/CD pipeline -- [`pre-commit.yaml`](../../scripts/config/pre-commit.yaml): Run the secret scanner as a pre-commit git hook ## Configuration checklist @@ -30,10 +72,18 @@ Scanning a repository for hard-coded secrets is a crucial security practice. "Ha ## Testing -You can execute and test the secret scanning across all commits locally on a developer's workstation using the following command +You can execute and test the secret scanning locally on a developer's workstation: + +**Staged changes only (fast):** + +```bash +check=staged-changes ./scripts/githooks/scan-secrets.sh +``` + +**Entire history (comprehensive):** -```shell -ALL_FILES=true ./scripts/githooks/scan-secrets.sh +```bash +check=whole-history ./scripts/githooks/scan-secrets.sh ``` ## Removing sensitive data From c5b67851bcc90edd676b940f430df0e2dc959f1e Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:25:28 +0100 Subject: [PATCH 6/8] fix: align Terraform module documentation and structure (#88) * fix(dependabot.yaml): align Terraform module paths in dependabot configuration * docs(context.tf): align context.tf files across all new modules * build: update module file structure guidelines and conditional requirements * style: standardize IAM capitalization across documentation and prompts * style(acm/main.tf): correct wording in comments to clarify module controls * docs(guardduty/README.md): enhance module documentation with detailed usage examples and conventions * refactor(iam/locals.tf): move locals configuration for IAM path and role policies to locals.tf * docs(iam/README.md): add enforcement details for IAM module controls and usage * docs(inspector/README.md): enhance module documentation with detailed usage examples and conventions * docs(kms/README.md): enhance documentation with detailed usage examples and conventions for KMS module * docs(README.md): ensure usage blocks use reference in examples * docs(lambda/README.md): enhance documentation with detailed usage examples and conventions for Lambda module * docs(license-manager/README.md): enhance documentation with detailed usage examples, conventions, and additional configuration options for License Manager module * refactor(license-manager/locals.tf): move locals configuration for IAM path and role policies to locals.tf * docs(license-manager/README.md): update module source references to use placeholder * docs(secrets-manager/README.md): update module source references to use full GitHub URL and enhance documentation with module limitations and conventions * docs(s3-bucket/README.md): update module source references to use placeholder * docs(security-hub/README.md): add conventions section detailing module defaults and configurations * docs(README.md): ensure usage blocks use reference in examples * docs(sns/README.md): enhance usage examples and conventions for SNS module * style: standardize IAM capitalization across documentation and prompts * docs(security-hub/README.md): update conventions section for clarity and formatting * docs(README.md): update module descriptions for clarity and consistency --- .github/agents/terraform-modules.agent.md | 7 +- .github/dependabot.yaml | 1 + .../terraform-modules.instructions.md | 27 +++-- .../prompts/new-terraform-module.prompt.md | 9 +- .github/skills/pre-commit-hooks.skill.md | 2 +- .../terraform-module-maintenance.skill.md | 9 +- .../skills/terraform-module-patterns.skill.md | 10 +- AGENTS.md | 4 +- README.md | 53 +++++---- .../user-guides/Pre_commit_hooks_reference.md | 2 +- infrastructure/modules/acm/README.md | 10 +- infrastructure/modules/acm/main.tf | 2 +- infrastructure/modules/guardduty/README.md | 107 ++++++++++++++++- infrastructure/modules/guardduty/context.tf | 2 +- infrastructure/modules/iam/README.md | 26 ++-- infrastructure/modules/iam/context.tf | 2 +- infrastructure/modules/iam/locals.tf | 17 +++ infrastructure/modules/iam/main.tf | 20 ---- infrastructure/modules/inspector/README.md | 111 +++++++++++++++++- infrastructure/modules/inspector/context.tf | 2 +- infrastructure/modules/kms/README.md | 110 ++++++++++++++--- infrastructure/modules/kms/context.tf | 2 +- infrastructure/modules/lambda/README.md | 111 +++++++++++++++++- infrastructure/modules/lambda/context.tf | 2 +- .../modules/license-manager/README.md | 96 ++++++++++++++- .../modules/license-manager/context.tf | 2 +- .../modules/license-manager/locals.tf | 6 + .../modules/license-manager/main.tf | 7 -- infrastructure/modules/s3-bucket/README.md | 8 +- infrastructure/modules/s3-bucket/context.tf | 2 +- .../modules/secrets-manager/README.md | 27 ++++- .../modules/secrets-manager/context.tf | 2 +- infrastructure/modules/security-hub/README.md | 21 +++- .../modules/security-hub/context.tf | 2 +- infrastructure/modules/sns/README.md | 91 +++++++++++++- infrastructure/modules/sns/context.tf | 2 +- infrastructure/modules/tags/README.md | 2 +- .../config/vocabularies/words/accept.txt | 11 +- 38 files changed, 788 insertions(+), 139 deletions(-) create mode 100644 infrastructure/modules/iam/locals.tf create mode 100644 infrastructure/modules/license-manager/locals.tf diff --git a/.github/agents/terraform-modules.agent.md b/.github/agents/terraform-modules.agent.md index 764e5343..c0e8ba3b 100644 --- a/.github/agents/terraform-modules.agent.md +++ b/.github/agents/terraform-modules.agent.md @@ -30,7 +30,7 @@ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git All modules follow the **wrapper module pattern**: 1. Wrap a community module (e.g., `terraform-aws-modules/*`) or native resources. -2. Enforce NHS security baseline (encryption, TLS, no public access, least-privilege iam). +2. Enforce NHS security baseline (encryption, TLS, no public access, least-privilege IAM). 3. Derive naming from `module.this.id` and tagging from `module.this.tags`. 4. Gate creation via `module.this.enabled`. 5. Pin upstream versions explicitly. @@ -58,7 +58,7 @@ Every module must enforce: | Encryption at rest | KMS or service-managed; no unencrypted storage | | Encryption in transit | TLS required where applicable | | No public access | Blocked by default at all available toggles | -| iam least-privilege | No `*` actions in policies | +| IAM least-privilege | No `*` actions in policies | | Logging | Enabled where the service supports it | | Tagging | All resources via `module.this.tags` | @@ -74,7 +74,7 @@ Every module must enforce: 8. Update README when changing module interfaces. 9. Use British English in comments and documentation. 10. Never hard-code secrets, account IDs, or ARNs. -11. Never use `*` in iam policy actions. +11. Never use `*` in IAM policy actions. 12. Never edit `context.tf` directly. ## Exemplar Modules @@ -84,3 +84,4 @@ When in doubt, reference: - `infrastructure/modules/s3-bucket` – full wrapper with security table, locals-based naming - `infrastructure/modules/iam` – multi-resource wrapper with per-resource iteration and label modules - `infrastructure/modules/secrets-manager` – simple wrapper with hard-coded security +- `infrastructure/modules/acm` – simple wrapper with opinionated security defaults diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 13604b7d..8a9b1736 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -35,6 +35,7 @@ updates: # BEGIN_AUTOGENERATED_TERRAFORM_UPDATES - package-ecosystem: "terraform" directories: + - "infrastructure/modules/acm" - "infrastructure/modules/api-gateway" - "infrastructure/modules/aws-backup-destination" - "infrastructure/modules/aws-backup-source" diff --git a/.github/instructions/terraform-modules.instructions.md b/.github/instructions/terraform-modules.instructions.md index bee2bbb0..5c7a0bb6 100644 --- a/.github/instructions/terraform-modules.instructions.md +++ b/.github/instructions/terraform-modules.instructions.md @@ -117,15 +117,21 @@ See `.github/skills/pre-commit-hooks.skill.md` for detailed documentation on all Every module must contain: -| File | Purpose | -| --- | --- | -| `main.tf` | Primary resource definitions with header comment block | -| `variables.tf` | Input variables with types, descriptions, defaults, validations | -| `outputs.tf` | Output values with descriptions | -| `versions.tf` | `required_version` and `required_providers` | -| `context.tf` | Tags context (copied from `tags/exports/context.tf`) | -| `locals.tf` | Derived/computed values (naming, defaults) | -| `README.md` | Usage documentation with examples | +| File | Purpose | Mandatory | +| --- | --- | --- | +| `main.tf` | Primary resource definitions with header comment block | Yes | +| `variables.tf` | Input variables with types, descriptions, defaults, validations | Yes | +| `outputs.tf` | Output values with descriptions | Yes | +| `versions.tf` | `required_version` and `required_providers` | Yes | +| `context.tf` | Tags context (copied from `tags/exports/context.tf`) | Yes | +| `data.tf` | Data sources (e.g., `data.aws_*`, `data.local_file`) | Only if data sources exist | +| `locals.tf` | Derived/computed values (naming, defaults) | Only if `locals {}` blocks exist | +| `README.md` | Usage documentation with examples | Yes | + +**Conditional file guidance:** + +- **`data.tf`**: Create this file if the module queries external data (e.g., `data.aws_availability_zones`, `data.aws_ami`). Store all data sources here for clarity. +- **`locals.tf`**: Create this file only if the module defines `locals {}` blocks for computed values, naming logic, or CIDR calculations. If no locals are needed, omit the file entirely. For any newly created module, `context.tf` must come from `infrastructure/modules/tags/exports/context.tf`, and the copied file must reference `source = "../tags"`. @@ -192,7 +198,7 @@ Every module must enforce: - Encryption at rest (KMS or service-managed) where applicable. - Encryption in transit (TLS required, deny insecure transport) where applicable. - No public access by default (block at all available toggles). -- iam least-privilege (no `*` actions in managed policies). +- IAM least-privilege (no `*` actions in managed policies). - Logging/audit enabled where the service supports it. - All resources tagged via `module.this.tags`. @@ -302,3 +308,4 @@ When in doubt, look at these compliant modules for reference: - `infrastructure/modules/iam` — Multi-resource wrapper (policies + roles) with per-resource iteration and label modules. - `infrastructure/modules/secrets-manager` — Simple wrapper with hard-coded security and optional features. - `infrastructure/modules/kms` — KMS key wrapper with policy enforcement. +- `infrastructure/modules/acm` — Simple wrapper with opinionated security defaults. diff --git a/.github/prompts/new-terraform-module.prompt.md b/.github/prompts/new-terraform-module.prompt.md index f2bd1cd1..fcde431b 100644 --- a/.github/prompts/new-terraform-module.prompt.md +++ b/.github/prompts/new-terraform-module.prompt.md @@ -93,12 +93,16 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.42" + version = ">= {{ upstream_min_version }}" # Use the upstream module's minimum; >= 6.42 is the platform baseline } } } ``` +> **Note:** Use whichever is higher — the upstream community module's stated minimum +> provider version, or the platform baseline of `>= 6.42`. Do not blindly apply the +> platform baseline if the upstream module works correctly at a lower version. + ### 5. `context.tf` Copy from `infrastructure/modules/tags/exports/context.tf`: @@ -197,7 +201,7 @@ Before finalising, verify the module enforces: - [ ] Encryption at rest (KMS or service-managed) - [ ] Encryption in transit (TLS required) where applicable - [ ] No public access by default -- [ ] iam least-privilege (no `*` actions) +- [ ] IAM least-privilege (no `*` actions) - [ ] Logging enabled where the service supports it - [ ] All resources tagged via `module.this.tags` - [ ] Creation gated by `module.this.enabled` @@ -224,3 +228,4 @@ Reference these for patterns: - `infrastructure/modules/s3-bucket` — full wrapper with comprehensive security - `infrastructure/modules/iam` — multi-resource wrapper with per-resource iteration - `infrastructure/modules/secrets-manager` — simple wrapper with hard-coded security +- `infrastructure/modules/acm` – simple wrapper with opinionated security defaults diff --git a/.github/skills/pre-commit-hooks.skill.md b/.github/skills/pre-commit-hooks.skill.md index e6ac26d8..8cfcba03 100644 --- a/.github/skills/pre-commit-hooks.skill.md +++ b/.github/skills/pre-commit-hooks.skill.md @@ -390,7 +390,7 @@ AWS credentials detected **Fix:** Never commit credentials. Use: - GitHub Secrets for CI/CD -- AWS assume role or iam OIDC federation +- AWS assume role or IAM OIDC federation - `~/.aws/credentials` for local development --- diff --git a/.github/skills/terraform-module-maintenance.skill.md b/.github/skills/terraform-module-maintenance.skill.md index 09794a93..0cb2f019 100644 --- a/.github/skills/terraform-module-maintenance.skill.md +++ b/.github/skills/terraform-module-maintenance.skill.md @@ -111,6 +111,13 @@ terraform-docs markdown . > README.md Pre-commit hooks will validate that README.md is in sync with the module's variables/outputs on every commit. +## Module File Structure Rules + +- `data.tf` is required only if the module uses data sources. +- `locals.tf` is required only if the module defines `locals {}` blocks. +- If these constructs are not used, the corresponding file should be omitted. +- If used, centralise all data sources in `data.tf` and local values in `locals.tf`. + ## Compliance & Security Review After upgrading a module: @@ -118,7 +125,7 @@ After upgrading a module: 1. **Check security baseline**: Verify that enforcement controls haven't been weakened. 2. **Confirm encryption defaults**: Ensure encryption settings still use fixed values. 3. **Confirm public access blocks**: Ensure public access blocks are still enabled. -4. **Confirm iam permissions**: Keep iam policies minimal (no `*` actions). +4. **Confirm IAM permissions**: Keep IAM policies minimal (no `*` actions). 5. **Review upstream breaking changes**: Check community module release notes for incompatible API changes. 6. **Validate outputs**: Ensure stable output names are preserved; consumers depend on them. 7. **Test in context**: If possible, apply the module in a test stack to confirm integration. diff --git a/.github/skills/terraform-module-patterns.skill.md b/.github/skills/terraform-module-patterns.skill.md index c15b3d61..5c6f9e93 100644 --- a/.github/skills/terraform-module-patterns.skill.md +++ b/.github/skills/terraform-module-patterns.skill.md @@ -181,7 +181,13 @@ variable "meaningful_name" { - Complex types → use `object({})` with `optional()` fields. - Sensitive values → mark with `sensitive = true`. -## Locals Pattern +## Conditional Module Files + +- `data.tf` is mandatory only when data sources exist in the module (for example `data.aws_*`, `data.local_file`, `data.external`). +- `locals.tf` is mandatory only when the module defines one or more `locals {}` blocks. +- When present, keep all data sources in `data.tf` and all local values in `locals.tf`. + +## Locals Pattern (When Locals Are Needed) ```hcl ################################################################ @@ -284,7 +290,7 @@ module that enforces the platform's baseline controls. \```hcl module "example" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/?ref=" service = "bcss" environment = "test" diff --git a/AGENTS.md b/AGENTS.md index 01a1836a..0fbec2ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Agents **must not**: When proposing a change, agents should: - Keep code formatted and idiomatic (Terraform HCL, Bash, YAML). -- Stick to existing patterns — look at compliant modules (`s3-bucket`, `iam`, `secrets-manager`, `kms`) as exemplars. +- Stick to existing patterns — look at compliant modules (`s3-bucket`, `iam`, `secrets-manager`, `kms`, `acm`) as exemplars. - **Run all pre-commit hooks before committing**: `pre-commit run --all-files` (see `.github/skills/pre-commit-hooks.skill.md` for details on each hook). - Run `terraform fmt -recursive` before committing. - Run `terraform validate` in affected module directories. @@ -94,7 +94,7 @@ Not all modules in this repository are currently compliant. The following tiers | Tier | Description | Examples | | --- | --- | --- | -| **Compliant** | Full wrapper pattern, `context.tf`, security baseline, proper variables/outputs/README | `s3-bucket`, `iam`, `secrets-manager`, `kms`, `sns` | +| **Compliant** | Full wrapper pattern, `context.tf`, security baseline, proper variables/outputs/README | `s3-bucket`, `iam`, `secrets-manager`, `kms`, `acm` | | **Partially compliant** | Has `context.tf` but may be missing validation, README, or security hardening | `lambda`, `ecr`, `vpc` | | **Legacy** | Older modules that predate the current conventions; may lack `context.tf` entirely | Various older modules | diff --git a/README.md b/README.md index 51b0acb3..5c446f4b 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ module "s3_bucket" { | Encryption at rest | KMS or service-managed; no unencrypted storage | | Encryption in transit | TLS required where applicable | | No public access | Blocked by default at all available toggles | -| iam least-privilege | No `*` actions in policies | +| IAM least-privilege | No `*` actions in policies | | Logging | Enabled where the service supports it | | Tagging | All resources via `module.this.tags` | @@ -297,40 +297,45 @@ Rules: | Module | Wraps | Description | | --- | --- | --- | -| `api-gateway` | — | API Gateway configuration | +| `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | +| `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | -| `aws-scheduler` | — | EventBridge Scheduler | -| `cognito` | — | Cognito user/identity pools | -| `cw-firehose-splunk` | — | CloudWatch to Splunk via Firehose | -| `ecr` | — | ECR repository | +| `aws-scheduler` | — | EventBridge Scheduler configuration | +| `cognito` | — | Cognito user and identity pools | +| `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | +| `ecr` | — | ECR repository with security controls | | `ecs-cluster` | — | ECS Fargate cluster | -| `elasticache` | — | ElastiCache cluster | -| `github-config` | — | GitHub OIDC and runner configuration | +| `ecs-service` | — | ECS service and task definition | +| `elasticache` | — | ElastiCache cluster (Redis/Memcached) | +| `github-config` | — | GitHub OIDC provider and runner configuration | | `guardduty` | — | GuardDuty threat detection | -| `iam` | `terraform-aws-modules/iam/aws` | iam policies and roles | +| `iam` | terraform-aws-modules/iam/aws | IAM policies and roles | | `inspector` | — | Inspector vulnerability scanning | -| `kms` | `terraform-aws-modules/kms/aws` | KMS key with policy enforcement | -| `lambda` | — | Lambda function | -| `lambda-layer` | — | Lambda layer | +| `kms` | terraform-aws-modules/kms/aws | KMS key with policy enforcement | +| `lambda` | terraform-aws-modules/lambda/aws | Lambda function with runtime and layers | +| `lambda-layer` | — | Lambda layer for function libraries | | `license-manager` | — | License Manager configuration | -| `parameter_store` | — | SSM Parameter Store | +| `network-firewall` | — | Network Firewall rules and policies | +| `parameter_store` | — | SSM Parameter Store configuration | +| `r53` | — | Route 53 DNS records (legacy) | | `r53-healthcheck` | — | Route 53 health checks | +| `rds` | — | RDS database instance (legacy) | | `rds-database` | — | RDS database (logical) | | `rds-gateway-ecs-task` | — | RDS gateway ECS task definition | | `rds-instance` | — | RDS instance | | `rds-users` | — | RDS user management | -| `s3` | — | S3 (legacy) | -| `s3-bucket` | `terraform-aws-modules/s3-bucket/aws` | S3 bucket with full security | -| `secrets-manager` | `terraform-aws-modules/secrets-manager/aws` | Secrets Manager | -| `security-hub` | — | Security Hub | -| `sns` | Native resources | SNS topic with encryption | -| `sqs` | — | SQS queue | -| `tags` | — | Foundation: naming and tagging context | -| `vpc` | — | VPC | -| `vpce` | — | VPC endpoint (single) | -| `vpces` | — | VPC endpoints (multiple) | -| `waf` | — | WAF web ACL | +| `s3` | — | S3 bucket (legacy) | +| `s3-bucket` | terraform-aws-modules/s3-bucket/aws | S3 bucket with full security baseline | +| `secrets-manager` | terraform-aws-modules/secrets-manager/aws | Secrets Manager for secure secret storage | +| `security-hub` | — | Security Hub for centralized security findings | +| `sns` | terraform-aws-modules/sns/aws | SNS topic with encryption and policies | +| `sqs` | — | SQS queue with encryption | +| `tags` | — | Foundation: naming and tagging context module | +| `vpc` | — | VPC with subnets, routing, and gateways | +| `vpce` | — | VPC endpoint (single service) | +| `vpces` | — | VPC endpoints (multiple services) | +| `waf` | — | WAF web ACL with rules | ## Pre-commit hooks diff --git a/docs/user-guides/Pre_commit_hooks_reference.md b/docs/user-guides/Pre_commit_hooks_reference.md index 55bf15ca..5d8eca97 100644 --- a/docs/user-guides/Pre_commit_hooks_reference.md +++ b/docs/user-guides/Pre_commit_hooks_reference.md @@ -520,7 +520,7 @@ AWS credentials detected 1. **Remove the credential immediately** 2. Use GitHub Secrets for CI/CD. -3. Use AWS iam assume role or OIDC federation. +3. Use AWS IAM assume role or OIDC federation. 4. Use `~/.aws/credentials` for local development (never commit). **Prevention:** Never paste real credentials anywhere. diff --git a/infrastructure/modules/acm/README.md b/infrastructure/modules/acm/README.md index 967f5f5a..5dfa7d9f 100644 --- a/infrastructure/modules/acm/README.md +++ b/infrastructure/modules/acm/README.md @@ -21,7 +21,7 @@ the shared `context.tf` for naming and tagging. ```hcl module "acm" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "portal" @@ -37,7 +37,7 @@ module "acm" { ```hcl module "acm" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "platform" @@ -61,7 +61,7 @@ module "acm" { ```hcl module "acm_private" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "internal" @@ -78,7 +78,7 @@ module "acm_private" { ```hcl module "acm_no_ct" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "portal" @@ -99,7 +99,7 @@ module "acm_no_ct" { ```hcl module "acm_validation_records" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "platform" diff --git a/infrastructure/modules/acm/main.tf b/infrastructure/modules/acm/main.tf index e0ff9703..a4d50b8c 100644 --- a/infrastructure/modules/acm/main.tf +++ b/infrastructure/modules/acm/main.tf @@ -2,7 +2,7 @@ # AWS Certificate Manager (ACM) # # A thin wrapper around the terraform-aws-modules/acm/aws module, -# enforcing the following opinions: +# enforcing the following controls: # # * new certificates are validated via DNS, specifically via Route53 # * certificates cannot be exported diff --git a/infrastructure/modules/guardduty/README.md b/infrastructure/modules/guardduty/README.md index e0be2491..662fdafd 100644 --- a/infrastructure/modules/guardduty/README.md +++ b/infrastructure/modules/guardduty/README.md @@ -1,5 +1,110 @@ # GuardDuty +NHS Screening wrapper for AWS GuardDuty that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Threat detection | GuardDuty detector enabled when `enable_detector = true` | +| S3 protection | S3 Data Events monitoring enabled by default (`s3_protection_enabled = true`) | +| EBS malware scanning | EBS Malware Protection enabled by default (`malware_protection_scan_ec2_ebs_volumes_enabled = true`) | +| Finding notifications | CloudWatch Event rule forwards findings to SNS by default (`enable_cloudwatch = true`) | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | + +## Usage + +### Minimal detector with default protections + +```hcl +module "guardduty" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "detector" + + enable_detector = true +} +``` + +### Production detector with all protections and SNS forwarding + +```hcl +module "guardduty" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "detector" + + enable_detector = true + + # Protection features + s3_protection_enabled = true + malware_protection_scan_ec2_ebs_volumes_enabled = true + kubernetes_audit_logs_enabled = true + lambda_network_logs_enabled = true + runtime_monitoring_enabled = true + + runtime_monitoring_additional_config = { + eks_addon_management_enabled = true + ecs_fargate_agent_management_enabled = true + ec2_agent_management_enabled = true + } + + # Forward findings to SNS + enable_cloudwatch = true + findings_notification_arn = module.sns_alerts.topic_arn + finding_publishing_frequency = "FIFTEEN_MINUTES" +} +``` + +### Detector with minimal protections (non-production use) + +```hcl +module "guardduty_dev" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "dev" + name = "detector" + + enable_detector = true + + # Minimal protections for cost control in dev + s3_protection_enabled = false + malware_protection_scan_ec2_ebs_volumes_enabled = false + kubernetes_audit_logs_enabled = false + lambda_network_logs_enabled = false + runtime_monitoring_enabled = false + + # Disable SNS forwarding in dev + enable_cloudwatch = false +} +``` + +## Conventions + +- `enable_detector` defaults to `false`; you must explicitly enable it. +- S3 protection (`s3_protection_enabled`) and EBS malware scanning (`malware_protection_scan_ec2_ebs_volumes_enabled`) default to `true`. +- Runtime monitoring features (EKS, ECS, Lambda) default to `false`; enable based on workload requirements. +- `runtime_monitoring_enabled` and `eks_runtime_monitoring_enabled` are mutually exclusive; `RUNTIME_MONITORING` already covers EKS. +- CloudWatch Event rule (`enable_cloudwatch`) defaults to `true` and creates a forwarding rule; provide `findings_notification_arn` to wire it to an SNS topic. +- `finding_publishing_frequency` defaults to `FIFTEEN_MINUTES` for faster detection; adjust to `ONE_HOUR` or `SIX_HOURS` if lower alert volume is acceptable. +- The EventBridge rule uses a separate context label (`findings`) so its name/tags are distinct from the detector. + +## What this module does NOT do + +- Create or manage SNS topics for findings; you must create the topic separately and pass its ARN via `findings_notification_arn`. +- Configure GuardDuty member accounts or delegated administrator relationships; this module manages standalone or master account detectors only. +- Export findings to S3 or other destinations; use AWS GuardDuty's native export configuration outside this module if required. +- Automatically enable runtime monitoring agents on EC2/ECS/EKS; the module enables the GuardDuty feature, but agent deployment is separate. + @@ -81,7 +186,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/guardduty/context.tf b/infrastructure/modules/guardduty/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/guardduty/context.tf +++ b/infrastructure/modules/guardduty/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/iam/README.md b/infrastructure/modules/iam/README.md index 421d715c..d50dfb41 100644 --- a/infrastructure/modules/iam/README.md +++ b/infrastructure/modules/iam/README.md @@ -1,6 +1,6 @@ -# iam +# IAM -Creates iam customer-managed policies and (optionally) iam roles for any +Creates IAM customer-managed policies and (optionally) IAM roles for any team on the screening platform. Thin wrapper around the community [`terraform-aws-modules/iam/aws`](https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest) submodules (`iam-policy` and `iam-role`), with naming and tagging supplied @@ -8,6 +8,16 @@ by the central `tags` module via `context.tf` — so every team gets consistent `///` paths and the standard NHS tag set automatically. +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| IAM path namespacing | Policies and roles use `///` path derived from context | +| Consistent naming | Names derived from `module.this.id` with per-resource attributes | +| Tagging | All policies and roles tagged via `module.this.tags` | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Map-driven interface | Single module call produces multiple policies/roles with stable keys | + ## Usage The module is map-driven — one invocation can produce many policies and @@ -15,7 +25,7 @@ roles. Three typical consumer patterns: ### 1. SSO customer-managed policies (no roles) -Use this when defining the iam policies that AWS Identity Center +Use this when defining the IAM policies that AWS Identity Center permission sets will reference. The SSO wiring itself (`aws_ssoadmin_permission_set`, `aws_ssoadmin_customer_managed_policy_attachment`, account assignments) lives in the consumer stack, not in this module. @@ -126,12 +136,12 @@ module "iam" { - **Naming.** Resource names are derived from `module.this.id` plus an `attributes` suffix — e.g. `-policy-` and `-role-`. -- **iam path.** Defaults to `///` from context. Override +- **IAM path.** Defaults to `///` from context. Override globally with `var.path` or per-entry with `entry.path`. - **Enabled switch.** Set `context.enabled = false` to disable the entire module (e.g. in development tfvars). All resources are gated by it. - **Descriptions.** Strongly encouraged on every policy and role — - whoever sees them in the iam console later will thank you. + whoever sees them in the IAM console later will thank you. - **Inline policies.** `inline_policies` is a map of name -> JSON document; the upstream `iam-role` submodule merges all documents into a single inline policy on the role, so the map keys are used only for caller-side @@ -142,8 +152,8 @@ module "iam" { - SSO permission sets, account assignments, group/user management — lives in the consumer stack via `aws_ssoadmin_*` and `aws_identitystore_*`. -- iam users, iam groups, SAML/OIDC identity providers -- Account-wide iam settings (password policy, account alias, MFA enforcement). +- IAM users, IAM groups, SAML/OIDC identity providers +- Account-wide IAM settings (password policy, account alias, MFA enforcement). @@ -208,7 +218,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/iam/context.tf b/infrastructure/modules/iam/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/iam/context.tf +++ b/infrastructure/modules/iam/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/iam/locals.tf b/infrastructure/modules/iam/locals.tf new file mode 100644 index 00000000..9146cae2 --- /dev/null +++ b/infrastructure/modules/iam/locals.tf @@ -0,0 +1,17 @@ +locals { + # Default IAM path falls back to a context-derived value so policies/roles + # are grouped under a predictable namespace, e.g. "/bcss/screening/". + default_iam_path = format( + "/%s/", + join("/", compact([module.this.service, module.this.project, module.this.environment])) + ) + iam_path = var.path != null ? var.path : local.default_iam_path + + # role_key -> { static_name => policy_arn } for attached policies. + role_policies = { + for role_key, role in var.roles : role_key => merge( + { for idx, arn in role.policy_arns : "external-${idx}" => arn }, + { for k in role.policy_keys : k => module.policies[k].arn } + ) + } +} diff --git a/infrastructure/modules/iam/main.tf b/infrastructure/modules/iam/main.tf index f878c0f8..2c1e3b84 100644 --- a/infrastructure/modules/iam/main.tf +++ b/infrastructure/modules/iam/main.tf @@ -10,16 +10,6 @@ # so this wrapper invokes the relevant submodules ################################################################ -locals { - # Default IAM path falls back to a context-derived value so policies/roles - # are grouped under a predictable namespace, e.g. "/bcss/screening/". - default_iam_path = format( - "/%s/", - join("/", compact([module.this.service, module.this.project, module.this.environment])) - ) - iam_path = var.path != null ? var.path : local.default_iam_path -} - ################################################################ # Per-policy and per-role label modules # @@ -71,16 +61,6 @@ module "policies" { # so all statements are merged into one inline policy per role. ################################################################ -locals { - # role_key -> { static_name => policy_arn } for attached policies. - role_policies = { - for role_key, role in var.roles : role_key => merge( - { for idx, arn in role.policy_arns : "external-${idx}" => arn }, - { for k in role.policy_keys : k => module.policies[k].arn } - ) - } -} - module "roles" { source = "terraform-aws-modules/iam/aws//modules/iam-role" version = "6.6.0" diff --git a/infrastructure/modules/inspector/README.md b/infrastructure/modules/inspector/README.md index ad67f62f..a2301dd2 100644 --- a/infrastructure/modules/inspector/README.md +++ b/infrastructure/modules/inspector/README.md @@ -8,6 +8,115 @@ Classic with consistent naming and tagging via the shared For Inspector v2 (continuous scanning of EC2, ECR and Lambda), build a separate module using the `aws_inspector2_*` resources. +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Periodic assessments | CloudWatch Event rule triggers Inspector on a schedule (`schedule_expression`) | +| Rule package validation | Only valid short identifiers (`cve`, `cis`, `nr`, `sbp`) are accepted | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | + +## Usage + +### Minimal Inspector Classic with CVE and CIS rules + +```hcl +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "classic" + + enabled_rules = ["cve", "cis"] +} +``` + +### Production Inspector with all rule packages and SNS notifications + +```hcl +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "full-scan" + + enabled_rules = ["cve", "cis", "nr", "sbp"] + + # Run assessments daily + schedule_expression = "rate(1 day)" + assessment_duration = "7200" + + # Send notifications to SNS + assessment_event_subscription = { + completed = { + event = "ASSESSMENT_RUN_COMPLETED" + topic_arn = module.sns_alerts.topic_arn + } + failed = { + event = "ASSESSMENT_RUN_STATE_CHANGED" + topic_arn = module.sns_alerts.topic_arn + } + } +} +``` + +### Custom IAM role for Inspector execution + +```hcl +resource "aws_iam_role" "inspector" { + name = "custom-inspector-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "inspector" { + role = aws_iam_role.inspector.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonInspectorServiceRolePolicy" +} + +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "custom-role" + + enabled_rules = ["cve", "cis"] + + create_iam_role = false + iam_role_arn = aws_iam_role.inspector.arn +} +``` + +## Conventions + +- `enabled_rules` is required and must contain at least one valid rule package identifier. +- Valid short identifiers: `cve` (Common Vulnerabilities & Exposures), `cis` (CIS benchmarks), `nr` (Network Reachability), `sbp` (Security Best Practices). +- `schedule_expression` defaults to `rate(7 days)`; adjust based on compliance requirements. +- `assessment_duration` defaults to `3600` seconds (1 hour); increase for larger environments. +- `create_iam_role` defaults to `false`; the upstream module creates a role if set to `true`, or you can provide an existing role via `iam_role_arn`. +- `assessment_event_subscription` is a map for stability; use descriptive keys like `completed`, `failed`, `started`. + +## What this module does NOT do + +- Support Inspector v2 (use native `aws_inspector2_*` resources for that). +- Create SNS topics for notifications; you must create the topic separately and pass its ARN. +- Install or configure the Inspector agent on EC2 instances; that is managed separately via Systems Manager or user data scripts. +- Support cross-account or organisation-wide Inspector delegation; this module manages standalone account deployments only. + @@ -72,7 +181,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/inspector/context.tf b/infrastructure/modules/inspector/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/inspector/context.tf +++ b/infrastructure/modules/inspector/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/kms/README.md b/infrastructure/modules/kms/README.md index c7903b40..f42f2674 100644 --- a/infrastructure/modules/kms/README.md +++ b/infrastructure/modules/kms/README.md @@ -1,29 +1,103 @@ # AWS KMS Terraform module -Terraform module to provision a [KMS](https://aws.amazon.com/kms/) key with alias. +Terraform module to provision a [KMS](https://aws.amazon.com/kms/) key with alias. Thin wrapper around the community [`terraform-aws-modules/kms/aws`](https://registry.terraform.io/modules/terraform-aws-modules/kms/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Key rotation | Automatic key rotation enabled by default (`enable_key_rotation = true`) | +| Deletion protection | 14-day deletion window by default (`deletion_window_in_days = 14`) | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Symmetric encryption | Defaults to `SYMMETRIC_DEFAULT` key spec for encryption use cases | ## Usage +### Minimal KMS key with default settings + +```hcl +module "kms_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "data" + environment = "prod" + name = "application-secrets" + + description = "KMS key for application secrets encryption" +} +``` + +### Production KMS key with custom policies + ```hcl - module "kms_key" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms" - - service = "bcss" - project = "bcss" - environment = "test" - stack = "bootstrap" - workspace = terraform.workspace - name = "terraform-state" - - label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] - - description = "KMS key for Terraform state bucket encryption" - deletion_window_in_days = 10 - enable_key_rotation = true - } +module "kms_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "bcss" + environment = "prod" + stack = "bootstrap" + name = "terraform-state" + + label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] + + description = "KMS key for Terraform state bucket encryption" + deletion_window_in_days = 30 + enable_key_rotation = true + + key_administrators = [ + "arn:aws:iam::123456789012:role/admin-role" + ] + + key_users = [ + "arn:aws:iam::123456789012:role/ecs-task-role", + "arn:aws:iam::123456789012:role/lambda-execution-role" + ] +} ``` +### Asymmetric key for signing + +```hcl +module "kms_signing_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "signing" + environment = "prod" + name = "code-signing" + + description = "KMS key for code signing" + customer_master_key_spec = "RSA_4096" + key_usage = "SIGN_VERIFY" + enable_key_rotation = false # Not supported for asymmetric keys + + key_users = [ + "arn:aws:iam::123456789012:role/ci-cd-role" + ] +} +``` + +## Conventions + +- `enable_key_rotation` defaults to `true` for symmetric keys; automatic rotation is not supported for asymmetric keys. +- `deletion_window_in_days` defaults to `14`; increase to 30 for production keys to allow for recovery. +- `customer_master_key_spec` defaults to `SYMMETRIC_DEFAULT`; use `RSA_*` or `ECC_*` specs for asymmetric encryption or signing. +- `key_usage` defaults to `ENCRYPT_DECRYPT`; set to `SIGN_VERIFY` for signing keys. +- `aliases` can be specified to create custom key aliases; if not provided, the alias is auto-generated from `module.this.id`. +- `key_administrators`, `key_users`, and `key_service_users` are all optional IAM ARN lists that control key policy permissions. +- `enable_default_policy` defaults to `true`; set to `false` only if providing a complete custom policy via `key_statements`. + +## What this module does NOT do + +- Create IAM roles or users; you must provide existing ARNs for key administrators/users. +- Encrypt or decrypt data; the module creates the key, but encryption operations are performed by services/applications. +- Support multi-region keys (currently uses single-region keys only). +- Manage key grants (use `aws_kms_grant` resources directly in consumer stacks if needed). + @@ -95,7 +169,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/kms/context.tf b/infrastructure/modules/kms/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/kms/context.tf +++ b/infrastructure/modules/kms/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/lambda/README.md b/infrastructure/modules/lambda/README.md index 24dedb23..88884d64 100644 --- a/infrastructure/modules/lambda/README.md +++ b/infrastructure/modules/lambda/README.md @@ -1,5 +1,114 @@ # Lambda +NHS Screening wrapper around the community [`terraform-aws-modules/lambda/aws`](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| IAM execution role | Automatically attaches AWS-managed policies for VPC access, CloudWatch Logs, and SQS execution | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Source path convention | Defaults to `../../lambdas//` for consistent repo layout | + +## Usage + +### Minimal Lambda function + +```hcl +module "lambda_processor" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "data" + environment = "prod" + name = "processor" + + handler_prefix = "process_records" + function_description = "Process incoming data records from SQS" + python_version = "python3.12" +} +``` + +### Lambda with VPC and environment variables + +```hcl +module "lambda_api" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "handler" + + handler_prefix = "api_handler" + function_description = "API Gateway Lambda handler" + python_version = "python3.12" + timeout = 30 + + vpc_subnet_ids = module.vpc.private_subnet_ids + vpc_security_group_ids = [module.security_group.id] + + environment_variables = { + DB_ENDPOINT = module.rds.endpoint + REGION = "eu-west-2" + LOG_LEVEL = "INFO" + } +} +``` + +### Lambda with layers and custom source path + +```hcl +module "lambda_with_layers" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "etl" + environment = "prod" + name = "transformer" + + handler_prefix = "transform_data" + function_description = "Transform data using shared utility layers" + python_version = "python3.12" + source_path = "${path.module}/../../lambdas/custom/transform_data/" + timeout = 120 + + layers = [ + "arn:aws:lambda:eu-west-2:123456789012:layer:shared-utils:5", + "arn:aws:lambda:eu-west-2:123456789012:layer:pandas:2" + ] + + environment_variables = { + BUCKET_NAME = module.s3.bucket_name + } +} +``` + +## Conventions + +- `handler_prefix` is required and determines the Lambda handler entry point (`.lambda_handler`). +- `function_description` is required for documentation and compliance. +- `python_version` defaults to `python3.11`; explicitly set it to pin runtime version. +- `source_path` defaults to `../../lambdas//` relative to the module; override for custom layouts. +- `timeout` defaults to `3` seconds; increase for long-running functions. +- The module automatically attaches four AWS-managed IAM policies: + - `AWSLambdaVPCAccessExecutionRole` (VPC networking) + - `AWSLambdaBasicExecutionRole` (CloudWatch Logs) + - `AmazonAPIGatewayPushToCloudWatchLogs` (API Gateway integration) + - `AWSLambdaSQSQueueExecutionRole` (SQS polling) +- Use the `layers` input to attach Lambda layers; provide full ARNs including version. +- `vpc_subnet_ids` and `vpc_security_group_ids` are optional; omit for non-VPC functions. + +## What this module does NOT do + +- Create VPCs, subnets, or security groups; you must provide existing resource IDs. +- Package Lambda code; use the `source_path` to point to pre-packaged code or let the upstream module handle packaging. +- Create Lambda layers; you must create layers separately and provide ARNs. +- Configure Lambda event sources (SQS, EventBridge, S3, etc.); use native `aws_lambda_event_source_mapping` or trigger resources in consumer stacks. +- Manage Lambda function versions or aliases; use native `aws_lambda_alias` resources if needed. +- Provide custom IAM policies beyond the four AWS-managed policies; use the `iam` module to create custom policies and attach them separately. + @@ -71,7 +180,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | 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 | | [timeout](#input\_timeout) | Timeout for the Lambda function in seconds | `number` | `120` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of VPC security group IDs for the Lambda function | `list(string)` | `[]` | no | diff --git a/infrastructure/modules/lambda/context.tf b/infrastructure/modules/lambda/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/lambda/context.tf +++ b/infrastructure/modules/lambda/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/license-manager/README.md b/infrastructure/modules/license-manager/README.md index 7ef5a0fe..68ad03e0 100644 --- a/infrastructure/modules/license-manager/README.md +++ b/infrastructure/modules/license-manager/README.md @@ -1,5 +1,99 @@ # License Manager +NHS Screening module for AWS License Manager license configurations. Creates self-managed license configurations (vCPU / Core / Socket / Instance counted) with optional hard limits and resource associations. Uses the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| License counting validation | Only valid counting types (`vCPU`, `Instance`, `Core`, `Socket`) are accepted | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Stable associations | Map-based `for_each` prevents unnecessary re-creation of resource associations | + +## Usage + +### Minimal license configuration (vCPU counted) + +```hcl +module "license_manager" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "platform" + environment = "prod" + name = "windows-server" + + description = "Windows Server BYOL licenses" + license_counting_type = "vCPU" + license_count = 100 +} +``` + +### License configuration with hard limit and rules + +```hcl +module "license_sql" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "database" + environment = "prod" + name = "sql-server-enterprise" + + description = "SQL Server Enterprise BYOL licenses" + license_counting_type = "Core" + license_count = 64 + license_count_hard_limit = true + + license_rules = [ + "#minimumCores=4", + "#allowedTenancy=EC2-DedicatedHost" + ] +} +``` + +### License configuration with AMI associations + +```hcl +module "license_windows_with_amis" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "compute" + environment = "prod" + name = "windows-2022-byol" + + description = "Windows Server 2022 BYOL licenses" + license_counting_type = "Instance" + license_count = 50 + + associated_resource_arns = { + windows-2022-base = "arn:aws:ec2:eu-west-2::image/ami-0123456789abcdef0" + windows-2022-iis = "arn:aws:ec2:eu-west-2::image/ami-0fedcba9876543210" + windows-2022-sql-web = "arn:aws:ec2:eu-west-2::image/ami-01234567890fedcba" + } +} +``` + +## Conventions + +- `license_counting_type` is required and must be one of `vCPU`, `Instance`, `Core`, or `Socket`. +- `license_count` is optional; when null, no count limit is tracked. +- `license_count_hard_limit` defaults to `false`; set to `true` to block further usage once the count is exceeded. +- `license_rules` is an optional list of rules in the format `#RuleType=RuleValue`; supported rule types include `minimumVcpus`, `maximumVcpus`, `minimumCores`, `maximumCores`, `minimumSockets`, `maximumSockets`, and `allowedTenancy`. +- `associated_resource_arns` is a map where keys are stable identifiers (e.g., `windows-ami-2022`) and values are ARNs of AMIs, EC2 instances, hosts, or other supported resources. Map-based approach prevents unnecessary re-creation when associations change. +- `name_override` can be used to provide a custom name; when null, the name is derived from `module.this.id`. +- **Important:** Removing `license_count` after it has been set is not supported by the License Manager API and requires resource replacement. + +## What this module does NOT do + +- Create AMIs, EC2 instances, or dedicated hosts; you must provide existing resource ARNs for association. +- Support product licenses from AWS Marketplace (use the License Manager console or native resources for those). +- Manage license grants across AWS Organisations or delegated administrator accounts (this module is for standalone account configurations only). +- Enforce license usage automatically on EC2 instance launch; License Manager tracking is passive unless you configure enforcement rules separately. +- Support license configurations for third-party License Manager integrations (e.g., bring-your-own-license agreements outside AWS). + @@ -68,7 +162,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/license-manager/context.tf b/infrastructure/modules/license-manager/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/license-manager/context.tf +++ b/infrastructure/modules/license-manager/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/license-manager/locals.tf b/infrastructure/modules/license-manager/locals.tf new file mode 100644 index 00000000..e057cf79 --- /dev/null +++ b/infrastructure/modules/license-manager/locals.tf @@ -0,0 +1,6 @@ +locals { + # Allow callers to override the generated name. Fall back to the + # context-derived id so naming stays consistent with sibling + # modules. + license_configuration_name = coalesce(var.name_override, module.this.id) +} diff --git a/infrastructure/modules/license-manager/main.tf b/infrastructure/modules/license-manager/main.tf index bc6b030f..b397ddd0 100644 --- a/infrastructure/modules/license-manager/main.tf +++ b/infrastructure/modules/license-manager/main.tf @@ -11,13 +11,6 @@ # screening service stacks and accounts. ################################################################ -locals { - # Allow callers to override the generated name. Fall back to the - # context-derived id so naming stays consistent with sibling - # modules. - license_configuration_name = coalesce(var.name_override, module.this.id) -} - ################################################################ # License configuration ################################################################ diff --git a/infrastructure/modules/s3-bucket/README.md b/infrastructure/modules/s3-bucket/README.md index 4de73540..eb41aabe 100644 --- a/infrastructure/modules/s3-bucket/README.md +++ b/infrastructure/modules/s3-bucket/README.md @@ -24,7 +24,7 @@ the shared `context.tf` for naming and tagging. ```hcl module "data_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "ingest" @@ -37,7 +37,7 @@ module "data_bucket" { ```hcl module "audit_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "audit" @@ -57,7 +57,7 @@ module "audit_bucket" { ```hcl module "log_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "platform" @@ -155,7 +155,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | 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 | | [versioning\_enabled](#input\_versioning\_enabled) | Whether object versioning is enabled. | `bool` | `true` | no | | [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/s3-bucket/context.tf b/infrastructure/modules/s3-bucket/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/s3-bucket/context.tf +++ b/infrastructure/modules/s3-bucket/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/secrets-manager/README.md b/infrastructure/modules/secrets-manager/README.md index 02a80ae4..df6f7eeb 100644 --- a/infrastructure/modules/secrets-manager/README.md +++ b/infrastructure/modules/secrets-manager/README.md @@ -14,7 +14,7 @@ Thin NHS wrapper around [terraform-aws-modules/secrets-manager/aws](https://regi ```hcl module "db_credentials" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "database" @@ -36,7 +36,7 @@ module "db_credentials" { ```hcl module "api_key" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "api" @@ -65,7 +65,7 @@ module "api_key" { ```hcl module "rotated_password" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "database" @@ -82,6 +82,25 @@ module "rotated_password" { } ``` +## Conventions + +- `block_public_policy` is always `true` and cannot be overridden — public access to secrets is never permitted. +- `recovery_window_in_days` defaults to `30`; set to `0` for immediate deletion (not recommended for production). +- `kms_key_id` is optional; when null, AWS uses the default `aws/secretsmanager` key. +- `secret_string` is the plaintext secret value (as a string or JSON-encoded object); use Terraform variables and `sensitive = true` to avoid exposure. +- `ignore_secret_changes` should be set to `true` when enabling rotation so Terraform does not overwrite the rotated value on the next apply. +- `create_random_password` generates a random password as the secret value; do not set `secret_string` when using this option. +- `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. + +## What this module does NOT do + +- Create KMS keys; you must provide an existing key ARN via `kms_key_id` or accept the default AWS-managed key. +- Create rotation Lambda functions; you must create the function separately and provide its ARN via `rotation_lambda_arn`. +- Populate secret values automatically; you must provide `secret_string` or enable `create_random_password`. +- Manage secret replicas across regions (use native `aws_secretsmanager_secret_rotation` resources if multi-region replication is required). +- Retrieve secret values; use `data.aws_secretsmanager_secret_version` in consumer stacks to read secrets. + @@ -154,7 +173,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/secrets-manager/context.tf b/infrastructure/modules/secrets-manager/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/secrets-manager/context.tf +++ b/infrastructure/modules/secrets-manager/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/security-hub/README.md b/infrastructure/modules/security-hub/README.md index 01e4ea6f..2e77f4e4 100644 --- a/infrastructure/modules/security-hub/README.md +++ b/infrastructure/modules/security-hub/README.md @@ -16,7 +16,7 @@ This wraps the upstream module in the same way as | Consistent naming & tagging | `context = module.this.context` forwarded to the upstream module | | `enabled` switch | Honoured via `module.this.context.enabled` | | Default standards on by default | `var.enable_default_standards = true` (AWS FSBP + CIS AWS Foundations) | -| Single source of SNS truth | `create_sns_topic = false`; findings forwarded to an existing topic arn | +| Single source of SNS truth | `create_sns_topic = false`; findings forwarded to an existing topic ARN | ## Pairing with GuardDuty @@ -32,7 +32,7 @@ shared SNS topic created by the separate alerting module via the ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -45,7 +45,7 @@ module "security_hub" { ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -64,7 +64,7 @@ module "security_hub" { ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -75,10 +75,19 @@ module "security_hub" { } ``` +## Conventions + +* `enable_default_standards` defaults to `true`, enabling AWS Foundational Security Best Practices (FSBP) and CIS AWS Foundations Benchmark. +* `enabled_standards` is an optional list of additional standards to enable (e.g., `"standards/pci-dss/v/3.2.1"`). +* `finding_aggregator_enabled` defaults to `false`; set to `true` to aggregate findings from multiple regions into the current region. +* `create_sns_topic` is always `false` (the module does not create its own topic); provide `findings_notification_arn` to forward findings to an existing SNS topic. +* GuardDuty findings are automatically ingested by Security Hub when both services are enabled in the same account/region; no additional configuration is required. +* Context-based naming and tagging via `module.this.context` is forwarded to the upstream CloudPosse module. + ## What this module does NOT do * Create the SNS topic that receives findings. That is owned by the alerting - module — pass its arn via `findings_notification_arn`. + module — pass its ARN via `findings_notification_arn`. * Create a KMS key. If the alerting SNS topic is KMS-encrypted, configure that inside the alerting module. * Manage Organization-wide Security Hub administration / member accounts. Those @@ -148,7 +157,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | -| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/security-hub/context.tf b/infrastructure/modules/security-hub/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/security-hub/context.tf +++ b/infrastructure/modules/security-hub/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/sns/README.md b/infrastructure/modules/sns/README.md index 3e7ce74d..fb39ca09 100644 --- a/infrastructure/modules/sns/README.md +++ b/infrastructure/modules/sns/README.md @@ -1,5 +1,94 @@ # SNS +NHS Screening wrapper around the community [`terraform-aws-modules/sns/aws`](https://registry.terraform.io/modules/terraform-aws-modules/sns/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Service publish permissions | Pre-configured topic policy allows EventBridge, ElastiCache, Backup, and ECS services to publish | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Standard (non-FIFO) topics | FIFO topics are disabled (`fifo_topic = false`) | + +## Usage + +### Minimal SNS topic + +```hcl +module "sns_alerts" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "platform" + environment = "prod" + name = "alerts" + aws_account_id = "123456789012" +} +``` + +### SNS topic with email subscription + +```hcl +module "sns_notifications" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "monitoring" + environment = "prod" + name = "notifications" + aws_account_id = "123456789012" + + subscriptions = { + email_team = { + protocol = "email" + endpoint = "team@example.nhs.uk" + } + } +} +``` + +### SNS topic with Lambda subscription and filter policy + +```hcl +module "sns_events" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "events" + environment = "prod" + name = "processor" + aws_account_id = "123456789012" + + subscriptions = { + lambda_processor = { + protocol = "lambda" + endpoint = module.lambda.function_arn + filter_policy = jsonencode({ + eventType = ["order.created", "order.updated"] + }) + } + } +} +``` + +## Conventions + +- `aws_account_id` is required for topic policy conditions (retained for compatibility with legacy stacks). +- `topic_name` can be used to override the context-derived name; when null, the name is derived from `module.this.id`. +- `fifo_topic` is always `false` (FIFO topics are not supported by this wrapper). +- `content_based_deduplication` is always `false` (relevant only for FIFO topics). +- The module pre-configures a topic policy allowing EventBridge, ElastiCache, AWS Backup, and ECS services to publish; these defaults preserve legacy behaviour and may be refined in future versions. +- `ecs_role_prefix` defaults to the topic name; set this explicitly if your ECS task roles use a different prefix. +- `subscriptions` is a map where keys are stable identifiers (e.g., `email_team`, `lambda_processor`) and values are subscription configurations. + +## What this module does NOT do + +- Create Lambda functions, SQS queues, or other endpoints; you must create those separately and provide ARNs/endpoints for subscriptions. +- Support FIFO topics (use native `aws_sns_topic` resources if FIFO is required). +- Manage cross-account publish permissions beyond the pre-configured service principals; use additional topic policy statements if cross-account access is needed. +- Automatically confirm email subscriptions; AWS sends a confirmation email that must be manually confirmed. + @@ -64,7 +153,7 @@ | [subscriptions](#input\_subscriptions) | Map of SNS subscriptions to create (passed through to terraform-aws-modules/sns/aws). |
map(object({
confirmation_timeout_in_minutes = optional(number)
delivery_policy = optional(string)
endpoint = string
endpoint_auto_confirms = optional(bool)
filter_policy = optional(string)
filter_policy_scope = optional(string)
protocol = string
raw_message_delivery = optional(bool)
redrive_policy = optional(string)
replay_policy = optional(string)
subscription_role_arn = optional(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 | +| [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 | | [topic\_name](#input\_topic\_name) | SNS topic name override. If null, the module derives a name from context (`module.this.id`). | `string` | `null` | no | | [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/sns/context.tf b/infrastructure/modules/sns/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/sns/context.tf +++ b/infrastructure/modules/sns/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/tags/README.md b/infrastructure/modules/tags/README.md index 43fb42aa..515f9189 100644 --- a/infrastructure/modules/tags/README.md +++ b/infrastructure/modules/tags/README.md @@ -54,7 +54,7 @@ The recommended convention is to use labels as follows: - `service`: A short (3-4 letters) abbreviation of the service directorate to ensure globally unique IDs for things like S3 buckets i.e. bcss - `project`: The name or role of the project the resource is for, such as `web` or `api` -- `region`: By default this will auto-populate the provider region, but can be overridden or set to `gbl` for resources like iam roles that have no region +- `region`: By default this will auto-populate the provider region, but can be overridden or set to `gbl` for resources like IAM roles that have no region - `environment`: The name or role of the account the resource is for, such as `prod` or `dev` - `workspace`: _(Rarely needed)_ Typically, the singular environment label suffices as there would only be a singular resource created per environment. On occasion, there may be multiple sub-environment, still of a singular environment/with shared environment resources i.e. sit1, sit2, nft1, nft2). `workspace` can be used to identify the specific sub-environment the resources relate to and by default is auto-populated to the `terraform.workspace` value. - `name`: The name of the component that owns the resources, such as `eks` or `rds` diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 856dde19..1e45aa2a 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -7,7 +7,7 @@ account_id actionlint api_gateway_name api_path_part -arn +(ARN|[Aa]rn) backup_copy_vault_account_id backup_vault_name batch_id @@ -42,8 +42,8 @@ handler_prefix healthcheck http_method idempotence -iam -itterate +(IAM|[Ii]am) +[Ii]ntra Jira jq jQuery @@ -63,6 +63,7 @@ Octokit onboarding [Oo]pen[Ii][Dd] [Oo]utcode +plaintext Podman preprod prewritten @@ -71,6 +72,7 @@ printFooter printHeader Python rbac_role +[Rr]eachability readonly recovery_window repo @@ -85,7 +87,7 @@ shortcode source_account_name stage_name start_time -subnet +[Ss]ubnet Syft [Mm]arkdownlint [Tt]eardown @@ -101,5 +103,6 @@ URL url vault_lock_type vault_name +(VPC|[Vv]pc) [Uu][Uu][Ii][Dd] yq From c6e6ce11ea1592ea221bbf2185fb6dc91d3cab97 Mon Sep 17 00:00:00 2001 From: DeepikaDK <118273562+DeepikaDK001@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:07:08 +0100 Subject: [PATCH 7/8] Added doc format fixes --- .github/dependabot.yaml | 1 + .gitleaksignore | 4 + .../modules/r53/.terraform.lock.hcl | 30 ++ infrastructure/modules/r53/README.md | 279 +++++++++++++ infrastructure/modules/r53/context.tf | 377 ++++++++++++++++++ infrastructure/modules/r53/locals.tf | 42 ++ infrastructure/modules/r53/main.tf | 200 ++++++++++ infrastructure/modules/r53/outputs.tf | 94 +++++ infrastructure/modules/r53/variables.tf | 164 ++++++++ infrastructure/modules/r53/versions.tf | 10 + scripts/config/gitleaks.toml | 27 +- 11 files changed, 1226 insertions(+), 2 deletions(-) create mode 100644 infrastructure/modules/r53/.terraform.lock.hcl create mode 100644 infrastructure/modules/r53/README.md create mode 100644 infrastructure/modules/r53/context.tf create mode 100644 infrastructure/modules/r53/locals.tf create mode 100644 infrastructure/modules/r53/main.tf create mode 100644 infrastructure/modules/r53/outputs.tf create mode 100644 infrastructure/modules/r53/variables.tf create mode 100644 infrastructure/modules/r53/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 8a9b1736..e1e87788 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -55,6 +55,7 @@ updates: - "infrastructure/modules/license-manager" - "infrastructure/modules/parameter_store" - "infrastructure/modules/r53-healthcheck" + - "infrastructure/modules/r53" - "infrastructure/modules/rds-database" - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" diff --git a/.gitleaksignore b/.gitleaksignore index f97f5c8a..bf9d628a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -5,3 +5,7 @@ e876843351a025eb754ec61982c8b7d95deeb709:.pre-commit-config.yaml:ipv4:119 e364bc1869c67729653c7efb4d6169f2294e68de:.pre-commit-config.yaml:ipv4:110 62088509f98ce02ce379adef2168b867eecfb5da:.pre-commit-config.yaml:ipv4:110 a3fa25da4e8f9eaa2e28c29f6196f23bfe87a58d:.pre-commit-config.yaml:ipv4:119 +# Historical false positive: example ARN comment in tags/main.tf contained hex-like content +# which triggered the ipv6 rule. Comment updated in later commit; old commits suppressed here. +7b49758d98757e8f404cb2c540c1f146afd6e395:infrastructure/modules/tags/main.tf:ipv6:131 +091dcd76884ffd307aee6c6b306b015c065f4896:infrastructure/modules/tags/main.tf:ipv6:131 diff --git a/infrastructure/modules/r53/.terraform.lock.hcl b/infrastructure/modules/r53/.terraform.lock.hcl new file mode 100644 index 00000000..10c97fd0 --- /dev/null +++ b/infrastructure/modules/r53/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.0.0, >= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/r53/README.md b/infrastructure/modules/r53/README.md new file mode 100644 index 00000000..882d3d59 --- /dev/null +++ b/infrastructure/modules/r53/README.md @@ -0,0 +1,279 @@ +# r53 + +Creates Route53 hosted zones, Route53 Resolver endpoints, and Route53 +Resolver DNS Firewall rule groups for screening shared-resource stacks. +This is a thin screening wrapper around the community +[`terraform-aws-modules/route53/aws`](https://registry.terraform.io/modules/terraform-aws-modules/route53/aws/latest) +module set, with naming and tagging supplied by the central `tags` module via +`context.tf`. + +## What this module enforces + +|Control|How it is enforced| +|-|-| +|Creation gate|All `for_each` resource maps are set to `{}` when `module.this.enabled = false`, preventing any resource creation| +|Context-derived naming and tagging|All resources inherit `module.this.tags`; resolver endpoint and firewall rule group names default to context-derived values| +|Firewall domain list de-duplication|Standalone `aws_route53_resolver_firewall_rule` resources are used for external/aws-managed firewall domain lists to prevent orphan empty domain lists| +|Per-item tag merging|Each resource merges `module.this.tags` with per-item `tags`, ensuring standard tags can never be stripped by a caller| + +## Usage + +### Private hosted zone with records and cross-account authorization + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + hosted_zones = { + internal = { + name = "screening.internal" + private_zone = true + ignore_vpc = true + + vpc = { + primary = { + vpc_id = module.vpc.vpc_id + vpc_region = "eu-west-2" + } + } + + vpc_association_authorizations = { + shared-services = { + vpc_id = "vpc-0123456789abcdef0" + vpc_region = "eu-west-2" + } + } + + records = { + api = { + type = "A" + alias = { + name = module.alb.dns_name + zone_id = module.alb.zone_id + } + } + } + } + } +} +``` + +### Inbound and outbound resolver endpoints + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_endpoints = { + inbound = { + direction = "INBOUND" + type = "IPV4" + protocols = ["Do53"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + security_group_ingress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow inbound DNS queries from subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow inbound DNS queries from subnet B" + } + } + + security_group_egress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow DNS responses to subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow DNS responses to subnet B" + } + } + } + + outbound = { + direction = "OUTBOUND" + type = "IPV4" + protocols = ["Do53", "DoH"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + rules = { + onprem = { + domain_name = "corp.internal." + rule_type = "FORWARD" + vpc_id = module.vpc.vpc_id + target_ip = [ + { ip = "10.20.0.10" }, + { ip = "10.20.0.11" }, + ] + } + } + } + } +} +``` + +### Resolver DNS Firewall rule group + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_firewall_rule_groups = { + default = { + rules = { + block-malware = { + priority = 100 + action = "BLOCK" + block_response = "NODATA" + domains = ["bad.example.", "malware.example."] + } + allow-aws = { + priority = 110 + action = "ALLOW" + domains = ["amazonaws.com.", "amazon.com."] + } + } + } + } +} +``` + +## Conventions + +- Hosted zone names are caller-supplied DNS names; the wrapper does not derive + them from `context.tf`. +- Resolver endpoint and firewall rule group names default to context-derived, + deterministic names based on the item key. +- All resources inherit the standard screening tag set, and per-item `tags` + are merged on top. +- Resolver endpoint resources default to `var.aws_region` when no per-item + `region` is supplied. + +## What this module does NOT do + +- Create `aws_route53_zone_association` resources for VPCs in other accounts. + As with the upstream module, only + `aws_route53_vpc_association_authorization` is handled here. +- Create `aws_route53_resolver_firewall_rule_group_association` resources. + Associate firewall rule groups to VPCs in the consumer stack once the shared + resource exists. +- Abstract every Route53 feature into separate screening opinions. This module + stays deliberately thin and forwards most behavior to the upstream module. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.51.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [hosted\_zones](#module\_hosted\_zones) | terraform-aws-modules/route53/aws | 6.5.0 | +| [resolver\_endpoint\_label](#module\_resolver\_endpoint\_label) | ../tags | n/a | +| [resolver\_endpoints](#module\_resolver\_endpoints) | terraform-aws-modules/route53/aws//modules/resolver-endpoint | 6.5.0 | +| [resolver\_firewall\_association\_label](#module\_resolver\_firewall\_association\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_group\_label](#module\_resolver\_firewall\_rule\_group\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_groups](#module\_resolver\_firewall\_rule\_groups) | terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group | 6.5.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_route53_resolver_firewall_rule.external](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule) | resource | +| [aws_route53_resolver_firewall_rule_group_association.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule_group_association) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [hosted\_zones](#input\_hosted\_zones) | Map of hosted zones to create or adopt.

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

`name` is the DNS zone name (for example `example.internal` or
`example.nhs.uk`) and is required. |
map(object({
name = string
create = optional(bool, true)
create_zone = optional(bool, true)
private_zone = optional(bool, false)
vpc_id = optional(string)
comment = optional(string)
delegation_set_id = optional(string)
force_destroy = optional(bool)
enable_accelerated_recovery = optional(bool)
ignore_vpc = optional(bool, false)
vpc = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
vpc_association_authorizations = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
enable_dnssec = optional(bool, false)
create_dnssec_kms_key = optional(bool, true)
dnssec_kms_key_arn = optional(string)
dnssec_kms_key_description = optional(string)
dnssec_kms_key_aliases = optional(list(string), [])
dnssec_kms_key_tags = optional(map(string), {})
dnssec_key_signing_key_name = optional(string)
records = optional(any, {})
tags = optional(map(string), {})
timeouts = optional(object({
create = optional(string)
update = optional(string)
delete = optional(string)
}))
}))
| `{}` | no | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [resolver\_endpoints](#input\_resolver\_endpoints) | Map of Route53 Resolver endpoints to create.

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

Each rule can either create a dedicated firewall domain list via `domains`
or reference an existing one via `firewall_domain_list_id`. |
map(object({
create = optional(bool, true)
region = optional(string)
name = optional(string)
ram_resource_associations = optional(map(object({
resource_share_arn = string
})), {})
vpc_ids = optional(map(string), {})
priority = optional(number, 100)
rules = optional(map(object({
name = optional(string)
domains = optional(list(string))
action = string
block_override_dns_type = optional(string)
block_override_domain = optional(string)
block_override_ttl = optional(number)
block_response = optional(string)
firewall_domain_list_id = optional(string)
firewall_domain_redirection_action = optional(string)
priority = number
q_type = optional(string)
tags = optional(map(string), {})
})), {})
tags = optional(map(string), {})
}))
| `{}` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [hosted\_zone\_arns](#output\_hosted\_zone\_arns) | Map of hosted zone key -> Route53 hosted zone ARN. | +| [hosted\_zone\_ids](#output\_hosted\_zone\_ids) | Map of hosted zone key -> Route53 hosted zone ID. | +| [hosted\_zone\_name\_servers](#output\_hosted\_zone\_name\_servers) | Map of hosted zone key -> authoritative name servers. | +| [hosted\_zone\_names](#output\_hosted\_zone\_names) | Map of hosted zone key -> Route53 hosted zone name. | +| [hosted\_zone\_records](#output\_hosted\_zone\_records) | Map of hosted zone key -> records created in the zone. | +| [resolver\_endpoint\_arns](#output\_resolver\_endpoint\_arns) | Map of resolver endpoint key -> endpoint ARN. | +| [resolver\_endpoint\_created\_security\_group\_ids](#output\_resolver\_endpoint\_created\_security\_group\_ids) | Map of resolver endpoint key -> created security group ID. | +| [resolver\_endpoint\_host\_vpc\_ids](#output\_resolver\_endpoint\_host\_vpc\_ids) | Map of resolver endpoint key -> host VPC ID. | +| [resolver\_endpoint\_ids](#output\_resolver\_endpoint\_ids) | Map of resolver endpoint key -> endpoint ID. | +| [resolver\_endpoint\_ip\_addresses](#output\_resolver\_endpoint\_ip\_addresses) | Map of resolver endpoint key -> endpoint IP addresses. | +| [resolver\_endpoint\_rules](#output\_resolver\_endpoint\_rules) | Map of resolver endpoint key -> resolver rules created by that endpoint module. | +| [resolver\_endpoint\_security\_group\_arns](#output\_resolver\_endpoint\_security\_group\_arns) | Map of resolver endpoint key -> created security group ARN. | +| [resolver\_endpoint\_security\_group\_ids](#output\_resolver\_endpoint\_security\_group\_ids) | Map of resolver endpoint key -> attached security group IDs. | +| [resolver\_firewall\_rule\_group\_arns](#output\_resolver\_firewall\_rule\_group\_arns) | Map of firewall rule group key -> rule group ARN. | +| [resolver\_firewall\_rule\_group\_domain\_lists](#output\_resolver\_firewall\_rule\_group\_domain\_lists) | Map of firewall rule group key -> domain lists created in that group. | +| [resolver\_firewall\_rule\_group\_ids](#output\_resolver\_firewall\_rule\_group\_ids) | Map of firewall rule group key -> rule group ID. | +| [resolver\_firewall\_rule\_group\_ram\_resource\_associations](#output\_resolver\_firewall\_rule\_group\_ram\_resource\_associations) | Map of firewall rule group key -> RAM resource associations created for that group. | +| [resolver\_firewall\_rule\_group\_rules](#output\_resolver\_firewall\_rule\_group\_rules) | Map of firewall rule group key -> firewall rules created in that group. | +| [resolver\_firewall\_rule\_group\_share\_statuses](#output\_resolver\_firewall\_rule\_group\_share\_statuses) | Map of firewall rule group key -> RAM share status. | + + + diff --git a/infrastructure/modules/r53/context.tf b/infrastructure/modules/r53/context.tf new file mode 100644 index 00000000..9a4652b0 --- /dev/null +++ b/infrastructure/modules/r53/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" { + # tflint-ignore: terraform_module_pinned_source + 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/r53/locals.tf b/infrastructure/modules/r53/locals.tf new file mode 100644 index 00000000..c6da6585 --- /dev/null +++ b/infrastructure/modules/r53/locals.tf @@ -0,0 +1,42 @@ +################################################################ +# Locals — Firewall Rule Group helpers +# +# These locals support the upstream defect workaround documented +# in main.tf. See the REVERT INSTRUCTIONS comment there for +# removal guidance once the upstream fix lands. +################################################################ + +locals { + # Rules that create their own domain list (passed to community module) + firewall_domain_rules = { + for gk, g in var.resolver_firewall_rule_groups : gk => { + for rk, r in g.rules : rk => r + if r.firewall_domain_list_id == null + } + } + + # Rules that reference an existing domain list (standalone resources) + firewall_external_rules = merge([ + for gk, g in var.resolver_firewall_rule_groups : { + for rk, r in g.rules : "${gk}/${rk}" => merge(r, { group_key = gk }) + if r.firewall_domain_list_id != null + } + ]...) +} + +################################################################ +# Locals — Firewall Rule Group VPC Associations +################################################################ + +locals { + firewall_vpc_associations = merge([ + for group_key, group in var.resolver_firewall_rule_groups : { + for vpc_key, vpc_id in group.vpc_ids : + "${group_key}-${vpc_key}" => { + group_key = group_key + vpc_id = vpc_id + priority = group.priority + } + } + ]...) +} diff --git a/infrastructure/modules/r53/main.tf b/infrastructure/modules/r53/main.tf new file mode 100644 index 00000000..dea2dc5c --- /dev/null +++ b/infrastructure/modules/r53/main.tf @@ -0,0 +1,200 @@ +################################################################ +# Route53 Module +# +# Screening wrapper around the community +# `terraform-aws-modules/route53/aws` modules: +# - root module -> hosted zones, records, DNSSEC +# - modules/resolver-endpoint -> Route53 Resolver endpoints and rules +# - modules/resolver-firewall-rule-group -> Resolver DNS Firewall rule groups +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +module "resolver_endpoint_label" { + source = "../tags" + for_each = var.resolver_endpoints + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-endpoint", each.key]) +} + +module "resolver_firewall_rule_group_label" { + source = "../tags" + for_each = var.resolver_firewall_rule_groups + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-firewall", each.key]) +} + +module "resolver_firewall_association_label" { + source = "../tags" + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-fw-assoc", each.key]) + id_length_limit = 64 +} + +module "hosted_zones" { + source = "terraform-aws-modules/route53/aws" + version = "6.5.0" + + for_each = module.this.enabled ? var.hosted_zones : {} + + comment = each.value.comment + create = each.value.create + create_dnssec_kms_key = each.value.create_dnssec_kms_key + create_zone = each.value.create_zone + delegation_set_id = each.value.delegation_set_id + dnssec_key_signing_key_name = each.value.dnssec_key_signing_key_name + dnssec_kms_key_aliases = each.value.dnssec_kms_key_aliases + dnssec_kms_key_arn = each.value.dnssec_kms_key_arn + dnssec_kms_key_description = each.value.dnssec_kms_key_description + dnssec_kms_key_tags = merge(module.this.tags, each.value.dnssec_kms_key_tags) + enable_accelerated_recovery = each.value.enable_accelerated_recovery + enable_dnssec = each.value.enable_dnssec + force_destroy = each.value.force_destroy + ignore_vpc = each.value.ignore_vpc + name = each.value.name + private_zone = each.value.private_zone + records = each.value.records + tags = merge(module.this.tags, each.value.tags) + timeouts = each.value.timeouts + vpc = each.value.vpc + vpc_association_authorizations = each.value.vpc_association_authorizations + vpc_id = each.value.vpc_id +} + +module "resolver_endpoints" { + source = "terraform-aws-modules/route53/aws//modules/resolver-endpoint" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_endpoints : {} + + create = each.value.create + direction = each.value.direction + ip_address = each.value.ip_address + name = coalesce(each.value.name, module.resolver_endpoint_label[each.key].id) + protocols = length(each.value.protocols) > 0 ? each.value.protocols : ["Do53"] + region = coalesce(each.value.region, var.aws_region) + rules = each.value.rules + tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.tags) + type = each.value.type + + create_security_group = each.value.create_security_group + security_group_description = each.value.security_group_description + security_group_egress_rules = each.value.security_group_egress_rules + security_group_ids = each.value.security_group_ids + security_group_ingress_rules = each.value.security_group_ingress_rules + security_group_name = coalesce(each.value.security_group_name, module.resolver_endpoint_label[each.key].id) + security_group_tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.security_group_tags) + security_group_use_name_prefix = each.value.security_group_use_name_prefix + vpc_id = each.value.vpc_id +} + +################################################################ +# Firewall Rule Groups +# +# UPSTREAM DEFECT (terraform-aws-modules/route53/aws v6.5.0) +# ────────────────────────────────────────────────────────── +# The community resolver-firewall-rule-group submodule creates +# an `aws_route53_resolver_firewall_domain_list` for EVERY rule +# in the `rules` map, regardless of whether a rule already +# supplies its own `firewall_domain_list_id` (e.g. an AWS- +# managed threat list or a RAM-shared domain list). +# +# In the rule resource it uses: +# firewall_domain_list_id = try( +# coalesce(each.value.firewall_domain_list_id, +# aws_route53_resolver_firewall_domain_list.this[each.key].id), +# null) +# +# So the provided ID takes precedence, but the duplicate +# customer-owned domain list is still created as an empty +# orphan. This wastes resources and causes confusion in the +# console (e.g. 4 empty customer-owned lists alongside 4 AWS- +# managed lists for the same threat categories). +# +# WORKAROUND +# ────────── +# We split each rule group's rules into two sets: +# +# 1. "domain" rules – rules where `domains` is populated and +# `firewall_domain_list_id` is null. These are passed to +# the community module which correctly creates a domain +# list and wires it to the rule. +# +# 2. "external" rules – rules where `firewall_domain_list_id` +# is set (AWS-managed lists, RAM-shared lists, or any +# pre-existing list). These bypass the community module +# entirely and are created as standalone +# `aws_route53_resolver_firewall_rule` resources attached +# to the same rule group. +# +# REVERT INSTRUCTIONS (when upstream is fixed) +# ───────────────────────────────────────────── +# Once the community module conditionally creates domain lists +# only for rules that do NOT supply `firewall_domain_list_id`: +# +# 1. Remove the `firewall_domain_rules` and +# `firewall_external_rules` locals in `locals.tf`. +# 2. Remove the `aws_route53_resolver_firewall_rule.external` +# resource block. +# 3. Change `module.resolver_firewall_rule_groups` to pass +# `rules = each.value.rules` instead of +# `rules = local.firewall_domain_rules[each.key]`. +# 4. Run `terraform plan` to confirm the external rules are +# adopted by the community module with no diff. +################################################################ + +module "resolver_firewall_rule_groups" { + source = "terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_firewall_rule_groups : {} + + create = each.value.create + name = coalesce(each.value.name, module.resolver_firewall_rule_group_label[each.key].id) + ram_resource_associations = each.value.ram_resource_associations + region = coalesce(each.value.region, var.aws_region) + rules = local.firewall_domain_rules[each.key] + tags = merge(module.resolver_firewall_rule_group_label[each.key].tags, each.value.tags) +} + +# Standalone rules for existing/AWS-managed domain lists. +# These bypass the community module to avoid orphan domain list +# creation (see UPSTREAM DEFECT comment above). +# REVERT: Remove this resource block once the upstream fix lands. +resource "aws_route53_resolver_firewall_rule" "external" { + for_each = module.this.enabled ? local.firewall_external_rules : {} + + action = each.value.action + block_override_dns_type = each.value.block_override_dns_type + block_override_domain = each.value.block_override_domain + block_override_ttl = each.value.block_override_ttl + block_response = each.value.block_response + firewall_domain_list_id = each.value.firewall_domain_list_id + firewall_domain_redirection_action = each.value.firewall_domain_redirection_action + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + name = coalesce(each.value.name, element(split("/", each.key), 1)) + priority = each.value.priority + q_type = each.value.q_type +} + +################################################################ +# Firewall Rule Group VPC Associations +# +# The community module does not create VPC associations, so we +# create them here. +################################################################ + +resource "aws_route53_resolver_firewall_rule_group_association" "this" { + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + name = module.resolver_firewall_association_label[each.key].id + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + vpc_id = each.value.vpc_id + priority = each.value.priority + + tags = module.resolver_firewall_association_label[each.key].tags +} diff --git a/infrastructure/modules/r53/outputs.tf b/infrastructure/modules/r53/outputs.tf new file mode 100644 index 00000000..ea73b874 --- /dev/null +++ b/infrastructure/modules/r53/outputs.tf @@ -0,0 +1,94 @@ +output "hosted_zone_ids" { + description = "Map of hosted zone key -> Route53 hosted zone ID." + value = { for key, zone in module.hosted_zones : key => zone.id } +} + +output "hosted_zone_arns" { + description = "Map of hosted zone key -> Route53 hosted zone ARN." + value = { for key, zone in module.hosted_zones : key => zone.arn } +} + +output "hosted_zone_names" { + description = "Map of hosted zone key -> Route53 hosted zone name." + value = { for key, zone in module.hosted_zones : key => zone.name } +} + +output "hosted_zone_name_servers" { + description = "Map of hosted zone key -> authoritative name servers." + value = { for key, zone in module.hosted_zones : key => zone.name_servers } +} + +output "hosted_zone_records" { + description = "Map of hosted zone key -> records created in the zone." + value = { for key, zone in module.hosted_zones : key => zone.records } +} + +output "resolver_endpoint_ids" { + description = "Map of resolver endpoint key -> endpoint ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.id } +} + +output "resolver_endpoint_arns" { + description = "Map of resolver endpoint key -> endpoint ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.arn } +} + +output "resolver_endpoint_host_vpc_ids" { + description = "Map of resolver endpoint key -> host VPC ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.host_vpc_id } +} + +output "resolver_endpoint_security_group_ids" { + description = "Map of resolver endpoint key -> attached security group IDs." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_ids } +} + +output "resolver_endpoint_security_group_arns" { + description = "Map of resolver endpoint key -> created security group ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_arn } +} + +output "resolver_endpoint_created_security_group_ids" { + description = "Map of resolver endpoint key -> created security group ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_id } +} + +output "resolver_endpoint_ip_addresses" { + description = "Map of resolver endpoint key -> endpoint IP addresses." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.ip_addresses } +} + +output "resolver_endpoint_rules" { + description = "Map of resolver endpoint key -> resolver rules created by that endpoint module." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.rules } +} + +output "resolver_firewall_rule_group_ids" { + description = "Map of firewall rule group key -> rule group ID." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.id } +} + +output "resolver_firewall_rule_group_arns" { + description = "Map of firewall rule group key -> rule group ARN." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.arn } +} + +output "resolver_firewall_rule_group_share_statuses" { + description = "Map of firewall rule group key -> RAM share status." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.share_status } +} + +output "resolver_firewall_rule_group_domain_lists" { + description = "Map of firewall rule group key -> domain lists created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.domain_lists } +} + +output "resolver_firewall_rule_group_rules" { + description = "Map of firewall rule group key -> firewall rules created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.rules } +} + +output "resolver_firewall_rule_group_ram_resource_associations" { + description = "Map of firewall rule group key -> RAM resource associations created for that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.ram_resource_associations } +} diff --git a/infrastructure/modules/r53/variables.tf b/infrastructure/modules/r53/variables.tf new file mode 100644 index 00000000..a8c43749 --- /dev/null +++ b/infrastructure/modules/r53/variables.tf @@ -0,0 +1,164 @@ +################################################################ +# Route53-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# `context.tf` via `module.this`. +################################################################ + +variable "hosted_zones" { + description = <<-EOT + Map of hosted zones to create or adopt. + + Each key is a logical identifier used only in Terraform state and outputs. + Each value forwards to the upstream `terraform-aws-modules/route53/aws` + root module. + + `name` is the DNS zone name (for example `example.internal` or + `example.nhs.uk`) and is required. + EOT + + type = map(object({ + name = string + create = optional(bool, true) + create_zone = optional(bool, true) + private_zone = optional(bool, false) + vpc_id = optional(string) + comment = optional(string) + delegation_set_id = optional(string) + force_destroy = optional(bool) + enable_accelerated_recovery = optional(bool) + ignore_vpc = optional(bool, false) + vpc = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + vpc_association_authorizations = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + enable_dnssec = optional(bool, false) + create_dnssec_kms_key = optional(bool, true) + dnssec_kms_key_arn = optional(string) + dnssec_kms_key_description = optional(string) + dnssec_kms_key_aliases = optional(list(string), []) + dnssec_kms_key_tags = optional(map(string), {}) + dnssec_key_signing_key_name = optional(string) + records = optional(any, {}) + tags = optional(map(string), {}) + timeouts = optional(object({ + create = optional(string) + update = optional(string) + delete = optional(string) + })) + })) + default = {} +} + +variable "resolver_endpoints" { + description = <<-EOT + Map of Route53 Resolver endpoints to create. + + Each key is a logical identifier used in Terraform state and outputs. + Names default to a context-derived value when `name` is omitted. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + direction = optional(string, "INBOUND") + type = optional(string) + protocols = optional(list(string), ["Do53"]) + ip_address = optional(list(object({ + ip = optional(string) + ipv6 = optional(string) + subnet_id = string + })), []) + security_group_ids = optional(list(string), []) + create_security_group = optional(bool, true) + security_group_name = optional(string) + security_group_use_name_prefix = optional(bool, false) + security_group_description = optional(string) + vpc_id = optional(string) + security_group_ingress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_egress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_tags = optional(map(string), {}) + rules = optional(map(object({ + domain_name = string + name = optional(string) + rule_type = string + tags = optional(map(string), {}) + target_ip = optional(list(object({ + ip = string + ipv6 = optional(string) + port = optional(number) + protocol = optional(string) + }))) + vpc_id = optional(string) + })), {}) + tags = optional(map(string), {}) + })) + default = {} + + validation { + condition = alltrue([ + for key, ep in var.resolver_endpoints : + alltrue([ + for p in ep.protocols : contains(["Do53", "DoH", "DoH-FIPS"], p) + ]) + ]) + error_message = "Each protocol value must be one of: Do53, DoH, DoH-FIPS. An empty list defaults to Do53." + } +} + +variable "resolver_firewall_rule_groups" { + description = <<-EOT + Map of Route53 Resolver DNS Firewall rule groups to create. + + Each rule can either create a dedicated firewall domain list via `domains` + or reference an existing one via `firewall_domain_list_id`. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + ram_resource_associations = optional(map(object({ + resource_share_arn = string + })), {}) + vpc_ids = optional(map(string), {}) + priority = optional(number, 100) + rules = optional(map(object({ + name = optional(string) + domains = optional(list(string)) + action = string + block_override_dns_type = optional(string) + block_override_domain = optional(string) + block_override_ttl = optional(number) + block_response = optional(string) + firewall_domain_list_id = optional(string) + firewall_domain_redirection_action = optional(string) + priority = number + q_type = optional(string) + tags = optional(map(string), {}) + })), {}) + tags = optional(map(string), {}) + })) + default = {} +} diff --git a/infrastructure/modules/r53/versions.tf b/infrastructure/modules/r53/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/r53/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index af5f0bb7..8371dcbc 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -11,8 +11,31 @@ regex = '''[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}''' [rules.allowlist] regexTarget = "match" regexes = [ - # Exclude the private network IPv4 addresses as well as the DNS servers for Google and OpenDNS - '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|208\.67\.222\.222|208\.67\.220\.220)''', + # Exclude private/reserved IPv4 addresses and well-known DNS servers used in docs/examples. + # Includes RFC5737 TEST-NET ranges: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|192\.0\.2\.[0-9]{1,3}|198\.51\.100\.[0-9]{1,3}|203\.0\.113\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1|1\.0\.0\.1)''', +] + +[[rules]] +description = "IPv6" +id = "ipv6" +# Matches valid IPv6 forms requiring at least 2 groups on each side of :: to +# avoid false positives from AWS ARNs (which use :: between region and account). +# full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +# compressed: 2001:db8::1, fe80:db8::1 +# trailing :: fe80:db8:: (2+ groups required before ::) +# leading :: ::db8:1 (2+ groups required after ::) +# Note: RE2 does not support lookahead/lookbehind so boundary enforcement is +# achieved structurally via minimum repetition counts. +regex = '''(?i)(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){2,7}:|(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}|(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}|(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}|(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:(?::[0-9a-f]{1,4}){1,6}|:(?::[0-9a-f]{1,4}){2,7}''' + +[rules.allowlist] +regexTarget = "match" +regexes = [ + # Exclude IPv6 documentation prefixes used in examples. + # RFC3849: 2001:db8::/32 + # RFC9637: 3fff::/20 (3fff:0000:: to 3fff:0fff::) + '''(?i)(^|[^0-9a-f])(2001:db8:|3fff:0[0-9a-f]{0,3}:)''', ] [allowlist] From 7b4042236071c675de2e75710a93d1d21deeab79 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Tue, 23 Jun 2026 17:26:51 +0100 Subject: [PATCH 8/8] Added doc format fixes --- infrastructure/modules/rds/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 437988f3..0c3aed31 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,12 +2,12 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## What this module enforces |Control|Value|Reason| -|-|-|-| +|---|---|---| |`publicly_accessible`|`false`|Databases must never be internet-facing| |`storage_encrypted`|`true`|Encryption at rest is mandatory| |`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| @@ -256,7 +256,7 @@ The master password arn is exposed via the `master_user_secret_arn` output. - Resource creation is gated by `module.this.enabled`. - Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. - Resource arn values (e.g., `instance_arn`) are exposed as output attributes. -- iam authentication and resource tagging require the instance resource ID. +- IAM authentication and resource tagging require the instance resource ID. ## What this module does NOT do