From 4541b805f123ed7e53b0ae50f28d0994a13da0dd Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Sun, 26 Jul 2026 23:02:49 +0100 Subject: [PATCH 1/3] feat(ses): add SES module with context-based naming and NHS tagging standards --- .github/dependabot.yaml | 1 + README.md | 1 + .../modules/ses/.terraform.lock.hcl | 56 +++ infrastructure/modules/ses/README.md | 202 ++++++++++ infrastructure/modules/ses/context.tf | 376 ++++++++++++++++++ infrastructure/modules/ses/locals.tf | 6 + infrastructure/modules/ses/main.tf | 61 +++ infrastructure/modules/ses/outputs.tf | 39 ++ infrastructure/modules/ses/validations.tf | 34 ++ infrastructure/modules/ses/variables.tf | 114 ++++++ infrastructure/modules/ses/versions.tf | 14 + 11 files changed, 904 insertions(+) create mode 100644 infrastructure/modules/ses/.terraform.lock.hcl create mode 100644 infrastructure/modules/ses/README.md create mode 100644 infrastructure/modules/ses/context.tf create mode 100644 infrastructure/modules/ses/locals.tf create mode 100644 infrastructure/modules/ses/main.tf create mode 100644 infrastructure/modules/ses/outputs.tf create mode 100644 infrastructure/modules/ses/validations.tf create mode 100644 infrastructure/modules/ses/variables.tf create mode 100644 infrastructure/modules/ses/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 26ae5fba..e2cff26e 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -74,6 +74,7 @@ updates: - "infrastructure/modules/secrets-manager" - "infrastructure/modules/security-group" - "infrastructure/modules/security-hub" + - "infrastructure/modules/ses" - "infrastructure/modules/sns" - "infrastructure/modules/sqs" - "infrastructure/modules/ssm-parameter" diff --git a/README.md b/README.md index 29670309..b459a73f 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ Rules: | `secrets-manager` | terraform-aws-modules/secrets-manager/aws | Secrets Manager for secure secret storage | | `security-group` | terraform-aws-modules/security-group/aws | Security group with ingress and egress rules | | `security-hub` | — | Security Hub for centralized security findings | +| `ses` | — | — | | `sns` | terraform-aws-modules/sns/aws | SNS topic with encryption and policies | | `sqs` | — | SQS queue with encryption | | `ssm-parameter` | — | — | diff --git a/infrastructure/modules/ses/.terraform.lock.hcl b/infrastructure/modules/ses/.terraform.lock.hcl new file mode 100644 index 00000000..47245f68 --- /dev/null +++ b/infrastructure/modules/ses/.terraform.lock.hcl @@ -0,0 +1,56 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudposse/awsutils" { + version = "0.20.1" + constraints = ">= 0.11.0" + hashes = [ + "h1:5u/q5dEDPxH3ME6/8nyCps/V1ia89ZS1qgX/fI2xd4I=", + "h1:6r2pii9sYLWC3UXHO7yu/ZPQFmfUltWT+o+Tr9jE3ns=", + "h1:bupB7ux2VTh9QE2KWX+2BZHL0bjYgg9hQmBSXV9mgiw=", + "h1:eQly6CBSBTFNP/lsmQrQmWWjAIMVwo8jeKlxxtoRfAo=", + "h1:vTH3PwQPqhf/SfgDXON+Bvkw6T/M4mfF7s0a9mEw76s=", + "zh:13892dc53f9f6af7d13665a95c11e8821538ced3fdb04fbac92f5346a677bf81", + "zh:4659d0e4e59608b81f08dd989f2674dcd549610454a0f1ed4c245c5f367af164", + "zh:47d879ff6611a6e401f92d125b57ef14ab6c848792b0347204daef57cb9700bc", + "zh:5c41a0ed4f2996a2bd62a8352dcc828f4ac3b157d209c4d9a6c0fefe4181c0c0", + "zh:6ccdec55c0f7d87b8f674a71b79a333f13cf781034f7feecd26e6782e90a902a", + "zh:8263fcf0be891adb101157ba8054094109f436b3fe974bb2da8f99ed55d2d5d2", + "zh:89cf2450d3fba0ac3646f4b9a37803e179583860801ef21201845165073737f9", + "zh:9fcb8243ce3cf9d4cfb8602ed8b31081fd59eac8570838959817aed385a31ad7", + "zh:a9c0f440eb77c77b25c99c4e7faf27cfdc5e80cd52fc8318ffcbc47f0db90ee1", + "zh:d42a6bc5e5fafb076f7d79038908602acff5a6ec727da06e7cbabcadf3ad8bb9", + "zh:eb14a3071ecd35fa38d12b8a688f125aadf9ce61db0d91c0024f9a3788bd0a73", + "zh:eefbba6dc993b636e209b3a7f2dd98dc9242572a319c0580e8725987d07341b6", + "zh:f52b3b8e830032b3c16b93c683cd0c543ceed83f60be71bcbfcb293665d5a76d", + "zh:f584e27d2c922289008034774c1b0b45af3c90714c588b7f1c17a84cce723690", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.56.0" + constraints = ">= 2.0.0, >= 6.14.0, >= 6.28.0" + hashes = [ + "h1:741DoGPD40A9X3e/30UA/TBlhhJFQwC6+1aLqM/v6r8=", + "h1:MhideOrA+/8rovaA9BC23G4dDrFNZ0mfrRMNptZBIS0=", + "h1:VAJZ8Z7LhSf7+LNNgYWB2V7Fd/WFxMbJ4hTdxf5Tf8I=", + "h1:gSTd4VOv0lEjCGP5deZ4hRMBZhIOUbtS4AlCML+3gIo=", + "h1:iWz9BgFQaDPA1ChVGYvgI3MlUnx3wSQCA2ggYqDPcz8=", + "zh:2b3fbb3bebcc663b85d5fd9bbc2d131ab89322d696ff5c6ac6b7ffb7b5fe92e7", + "zh:30e56ccc7f33a7778ab323a28fe893d8e9200dc5fb92ccb7023bee808db3c1b0", + "zh:67dca271bef16547ef8ab5a6349f9bce39d91d7c1ae3d8388ada687ca774ba44", + "zh:824c812695b14d2fddad5e22339d4520e16d9c875f5d5095f29003f49a6fd124", + "zh:8372b12e30078d1df8b52e1285fd0d9d35160a7d18c3b0211f55229e5a832fd3", + "zh:8922b45ab65e272e8951f70d890444ddb3441170d8fa6f51298f24f20d21943d", + "zh:8fc4fb94ac8547717128cbcf296ac0ba0918738c4882029cbba46bd4812eb5e4", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a2efcd0174b79c1dffce48a5984f137ebc6f67d2c2ee966b47210e1faeb05cf2", + "zh:a4a50aa269a19c1d664b0dd59ee8047ed8a4a5b449f54733518309feccce1f43", + "zh:a830d80316b3c3127c9e0c77b9d4f50702c9d1a884c08973ac50b326d9ac734a", + "zh:d7e1b9bb2df3cdde8381ab101a807ae54e72c156d13a6b73dae2b922dca0c9f5", + "zh:d87c2a1cfc03b01cf13dff3700898ab990e1817326728824da8499dd0129da72", + "zh:fbb1848a597263c66dc6587ec8c3e0586e505e36f99d23752763534f62a3fef7", + "zh:fcb54d08eb3c763f01223e12b4eacbd018478e8e67c6b8611940564dfbea3d90", + "zh:ff6df70b2b2f75bd9000630f131c02e6bc2b9172cdb2547030f3fc019a3f3bc5", + ] +} diff --git a/infrastructure/modules/ses/README.md b/infrastructure/modules/ses/README.md new file mode 100644 index 00000000..03244e4f --- /dev/null +++ b/infrastructure/modules/ses/README.md @@ -0,0 +1,202 @@ +# AWS SES Terraform module + +Thin NHS wrapper around [cloudposse/ses/aws](https://registry.terraform.io/modules/cloudposse/ses/aws) that enforces the screening platform's baseline controls. + +## Fixed controls + +| Setting | Value | Reason | +| --- | --- | --- | +| `iam_create_access_key` | `false` | IAM access keys must never be stored in Terraform state | +| `iam_create_ses_smtp_password` | `false` | SMTP passwords must never be stored in Terraform state | + +## Provider requirements + +The upstream `cloudposse/ses/aws` module requires the `cloudposse/awsutils` provider. Add it to the consuming stack's `versions.tf` and `providers.tf`: + +```hcl +# versions.tf +terraform { + required_providers { + awsutils = { + source = "cloudposse/awsutils" + version = ">= 0.11.0" + } + } +} + +# providers.tf +provider "awsutils" { + region = var.aws_region +} +``` + +## Usage + +### Domain identity with Route53 verification + +```hcl +module "ses" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ses?ref=" + + context = module.this.context + name = "ses" + + domain = "example.nhs.uk" + zone_id = module.r53.zone_id + + verify_domain = true + verify_dkim = true + + custom_from_subdomain = ["mail"] + custom_from_dns_record_enabled = true +} +``` + +### Domain identity without Route53 (DNS managed externally) + +```hcl +module "ses" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ses?ref=" + + context = module.this.context + name = "ses" + + domain = "example.nhs.uk" + # zone_id omitted — use the ses_domain_identity_verification_token and + # ses_dkim_tokens outputs to add the required DNS records manually +} +``` + +### With IAM group for sending + +```hcl +module "ses" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ses?ref=" + + context = module.this.context + name = "ses" + + domain = "example.nhs.uk" + zone_id = module.r53.zone_id + + verify_domain = true + verify_dkim = true + ses_group_enabled = true +} +``` + +## Conventions + +* The SES domain identity name is the `domain` value itself; it is not derived from context labels. +* The IAM group name is always derived from `module.this.id` (context labels) to align with NHS naming conventions. +* `verify_domain`, `verify_dkim`, `create_spf_record`, and `custom_from_dns_record_enabled` all require `zone_id` to be set — the module will fail at plan time if they are enabled without one. +* `ses_user_enabled` and `ses_group_enabled` both default to `false`; opt in explicitly when a sending identity is required. +* IAM access keys and SMTP passwords are hardcoded to never be stored in Terraform state — distribute credentials out-of-band via the AWS console or CLI after creation. +* Every AWS account starts in SES Sandbox mode. Sending to unverified addresses requires a production access request via AWS Support. + +## What this module does NOT do + +* Move the account out of SES Sandbox mode — raise an AWS Support request to enable production sending. +* Create or manage Route53 hosted zones — provide an existing zone ID via `zone_id`. +* Store IAM credentials — access keys and SMTP passwords must be retrieved and distributed out-of-band. +* Configure SES sending quotas, suppression lists, or configuration sets — manage those resources separately. +* Create an SES email identity for individual addresses — this module handles domain identities only. + +## Validation + +The following constraints are enforced at `plan` time via preconditions in `validations.tf`: + +* **Route53 dependency**: `verify_domain`, `verify_dkim`, `create_spf_record`, and `custom_from_dns_record_enabled` (when `custom_from_subdomain` is non-empty) all require `zone_id` to be set. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.28 | +| [awsutils](#requirement\_awsutils) | >= 0.11.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [terraform](#provider\_terraform) | n/a | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ses](#module\_ses) | cloudposse/ses/aws | 0.25.2 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | 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 | +| [create\_spf\_record](#input\_create\_spf\_record) | When true, creates an SPF TXT record in Route53 for the domain. Requires zone\_id to be set. | `bool` | `false` | no | +| [custom\_from\_behavior\_on\_mx\_failure](#input\_custom\_from\_behavior\_on\_mx\_failure) | The behaviour when the MX record for the custom MAIL FROM domain cannot be found. Valid values: UseDefaultValue (fall back to amazonses.com), RejectMessage (reject the outbound email). | `string` | `"UseDefaultValue"` | no | +| [custom\_from\_dns\_record\_enabled](#input\_custom\_from\_dns\_record\_enabled) | When true, creates a Route53 MX record for the custom MAIL FROM subdomain. Only takes effect when custom\_from\_subdomain is non-empty. Requires zone\_id to be set. | `bool` | `false` | no | +| [custom\_from\_subdomain](#input\_custom\_from\_subdomain) | List of subdomains to use as the MAIL FROM (return-path) address. For example, ["mail"] sets the MAIL FROM domain to mail.. Required for DMARC alignment when verify\_dkim is true. | `list(string)` | `[]` | 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 | +| [domain](#input\_domain) | The domain to create the SES identity for, e.g. "example.nhs.uk". | `string` | n/a | yes | +| [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 | +| [iam\_allowed\_resources](#input\_iam\_allowed\_resources) | List of resource ARNs that the IAM permissions apply to. Wildcards are accepted. When empty, the policy applies to all resources (`"*"`). | `list(string)` | `[]` | no | +| [iam\_permissions](#input\_iam\_permissions) | List of IAM action strings granted to the SES IAM user or group. | `list(string)` |
[
"ses:SendRawEmail"
]
| 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 | +| [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 | +| [ses\_group\_enabled](#input\_ses\_group\_enabled) | When true, creates an IAM group with permission to send emails via SES. The group name is derived from context labels via module.this.id. | `bool` | `false` | no | +| [ses\_group\_path](#input\_ses\_group\_path) | The IAM path for the SES IAM group. | `string` | `"/"` | no | +| [ses\_user\_enabled](#input\_ses\_user\_enabled) | When true, creates an IAM user with permission to send emails via SES. Access key and SMTP password are never stored in Terraform state — distribute credentials out-of-band. | `bool` | `false` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [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 | +| [verify\_dkim](#input\_verify\_dkim) | When true, creates Route53 CNAME records for DKIM signing verification. Requires zone\_id to be set. | `bool` | `false` | no | +| [verify\_domain](#input\_verify\_domain) | When true, creates a Route53 TXT record for SES domain ownership verification. Requires zone\_id to be set. | `bool` | `false` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | +| [zone\_id](#input\_zone\_id) | Route53 parent zone ID. When provided, the module creates Route53 DNS records for domain verification, DKIM, SPF, and the custom MAIL FROM MX record. Leave empty to manage DNS records outside Terraform. | `string` | `""` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [custom\_from\_domain](#output\_custom\_from\_domain) | The custom MAIL FROM domain (e.g. mail.example.nhs.uk). Empty when custom\_from\_subdomain is not set. | +| [ses\_dkim\_tokens](#output\_ses\_dkim\_tokens) | List of DKIM tokens to add as CNAME records in your DNS zone to enable DKIM signing. Only required when zone\_id is not provided and verify\_dkim is managed externally. | +| [ses\_domain\_identity\_arn](#output\_ses\_domain\_identity\_arn) | The ARN of the SES domain identity. | +| [ses\_domain\_identity\_verification\_token](#output\_ses\_domain\_identity\_verification\_token) | The TXT record value to add to your DNS zone to verify SES domain ownership. Only required when zone\_id is not provided and verify\_domain is managed externally. | +| [ses\_group\_name](#output\_ses\_group\_name) | The name of the IAM group created for SES sending. Empty when ses\_group\_enabled is false. | +| [spf\_record](#output\_spf\_record) | The SPF TXT record value. Add this to your DNS zone when create\_spf\_record is false and you manage DNS records externally. | +| [user\_arn](#output\_user\_arn) | The ARN of the IAM user created for SES sending. Empty when ses\_user\_enabled is false. | +| [user\_name](#output\_user\_name) | The name of the IAM user created for SES sending. Empty when ses\_user\_enabled is false. | + diff --git a/infrastructure/modules/ses/context.tf b/infrastructure/modules/ses/context.tf new file mode 100644 index 00000000..e934a84f --- /dev/null +++ b/infrastructure/modules/ses/context.tf @@ -0,0 +1,376 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "../tags" + + enabled = var.enabled + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/ses/locals.tf b/infrastructure/modules/ses/locals.tf new file mode 100644 index 00000000..f05a2b5c --- /dev/null +++ b/infrastructure/modules/ses/locals.tf @@ -0,0 +1,6 @@ +locals { + # Override the cloudposse IAM group name to follow NHS context-based naming conventions. + # Without this, cloudposse's label module would generate its own name + # that does not align with the NHS screening platform's standard. + ses_group_name = module.this.id +} diff --git a/infrastructure/modules/ses/main.tf b/infrastructure/modules/ses/main.tf new file mode 100644 index 00000000..03cfd710 --- /dev/null +++ b/infrastructure/modules/ses/main.tf @@ -0,0 +1,61 @@ +################################################################ +# SES Domain Identity +# +# Thin NHS wrapper around cloudposse/ses/aws that enforces the +# screening platform's baseline controls: +# +# * IAM credentials: access keys never written to Terraform state +# * SMTP passwords: never written to Terraform state +# * IAM group name: derived from context labels via module.this.id +# * Tagging: all NHS-required tags applied automatically +# * Enabled flag: create = module.this.enabled +# +# Inputs intentionally NOT exposed (hardcoded below): +# - iam_create_access_key → always false; credentials must not be stored in state +# - iam_create_ses_smtp_password → always false; credentials must not be stored in state +# - ses_group_name → derived from module.this.id via locals.tf +# +# Cross-variable input constraints are enforced in validations.tf. +# +# NOTE: This module requires the cloudposse/awsutils provider to be +# configured in the calling stack, even when ses_user_enabled = false, +# because the cloudposse/ses upstream module declares it as a required provider. +################################################################ + +module "ses" { + source = "cloudposse/ses/aws" + version = "0.25.2" + + enabled = module.this.enabled + + # SES domain identity — the domain itself is the primary resource name + domain = var.domain + zone_id = var.zone_id + + # DNS verification records (require zone_id) + verify_domain = var.verify_domain + verify_dkim = var.verify_dkim + create_spf_record = var.create_spf_record + + # Custom MAIL FROM domain + custom_from_subdomain = var.custom_from_subdomain + custom_from_dns_record_enabled = var.custom_from_dns_record_enabled + custom_from_behavior_on_mx_failure = var.custom_from_behavior_on_mx_failure + + # IAM — opt-in; off by default + ses_user_enabled = var.ses_user_enabled + ses_group_enabled = var.ses_group_enabled + ses_group_name = local.ses_group_name + ses_group_path = var.ses_group_path + + iam_permissions = var.iam_permissions + iam_allowed_resources = var.iam_allowed_resources + + # Security baseline: IAM credentials must never be stored in Terraform state. + # Rotate and distribute credentials out-of-band (e.g. via the AWS console or CLI). + iam_create_access_key = false # hardcoded — access keys in state are prohibited + iam_create_ses_smtp_password = false # hardcoded — SMTP passwords in state are prohibited + + # Tags — automatically populated from context + tags = module.this.tags +} diff --git a/infrastructure/modules/ses/outputs.tf b/infrastructure/modules/ses/outputs.tf new file mode 100644 index 00000000..c35a2eaf --- /dev/null +++ b/infrastructure/modules/ses/outputs.tf @@ -0,0 +1,39 @@ +output "ses_domain_identity_arn" { + description = "The ARN of the SES domain identity." + value = module.ses.ses_domain_identity_arn +} + +output "ses_domain_identity_verification_token" { + description = "The TXT record value to add to your DNS zone to verify SES domain ownership. Only required when zone_id is not provided and verify_domain is managed externally." + value = module.ses.ses_domain_identity_verification_token +} + +output "ses_dkim_tokens" { + description = "List of DKIM tokens to add as CNAME records in your DNS zone to enable DKIM signing. Only required when zone_id is not provided and verify_dkim is managed externally." + value = module.ses.ses_dkim_tokens +} + +output "spf_record" { + description = "The SPF TXT record value. Add this to your DNS zone when create_spf_record is false and you manage DNS records externally." + value = module.ses.spf_record +} + +output "custom_from_domain" { + description = "The custom MAIL FROM domain (e.g. mail.example.nhs.uk). Empty when custom_from_subdomain is not set." + value = module.ses.custom_from_domain +} + +output "user_arn" { + description = "The ARN of the IAM user created for SES sending. Empty when ses_user_enabled is false." + value = module.ses.user_arn +} + +output "user_name" { + description = "The name of the IAM user created for SES sending. Empty when ses_user_enabled is false." + value = module.ses.user_name +} + +output "ses_group_name" { + description = "The name of the IAM group created for SES sending. Empty when ses_group_enabled is false." + value = module.ses.ses_group_name +} diff --git a/infrastructure/modules/ses/validations.tf b/infrastructure/modules/ses/validations.tf new file mode 100644 index 00000000..35317b06 --- /dev/null +++ b/infrastructure/modules/ses/validations.tf @@ -0,0 +1,34 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * verify_domain, verify_dkim, and create_spf_record all require +# zone_id to be set (Route53 records cannot be created without it) +# * custom_from_dns_record_enabled requires zone_id when +# custom_from_subdomain is non-empty +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = !var.verify_domain || var.zone_id != "" + error_message = "verify_domain requires zone_id to be set." + } + precondition { + condition = !var.verify_dkim || var.zone_id != "" + error_message = "verify_dkim requires zone_id to be set." + } + precondition { + condition = !var.create_spf_record || var.zone_id != "" + error_message = "create_spf_record requires zone_id to be set." + } + precondition { + condition = !var.custom_from_dns_record_enabled || length(var.custom_from_subdomain) == 0 || var.zone_id != "" + error_message = "custom_from_dns_record_enabled requires zone_id when custom_from_subdomain is non-empty." + } + } +} diff --git a/infrastructure/modules/ses/variables.tf b/infrastructure/modules/ses/variables.tf new file mode 100644 index 00000000..1aeaf54d --- /dev/null +++ b/infrastructure/modules/ses/variables.tf @@ -0,0 +1,114 @@ +################################################################ +# SES-specific inputs. +# +# Naming, tagging, and the master `enabled` switch come from +# context.tf via `module.this`. +# +# Inputs NOT exposed here (opinionated defaults hardcoded in main.tf): +# - iam_create_access_key → always false (credentials must not be stored in state) +# - iam_create_ses_smtp_password → always false (credentials must not be stored in state) +# - ses_group_name → derived from module.this.id via locals.tf +################################################################ + +################################################################ +# Domain identity +################################################################ + +variable "domain" { + type = string + description = "The domain to create the SES identity for, e.g. \"example.nhs.uk\"." + + validation { + condition = can(regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\\.)+[A-Za-z]{2,63}$", var.domain)) + error_message = "domain must be a valid fully-qualified domain name, e.g. \"example.nhs.uk\"." + } +} + +variable "zone_id" { + type = string + default = "" + description = "Route53 parent zone ID. When provided, the module creates Route53 DNS records for domain verification, DKIM, SPF, and the custom MAIL FROM MX record. Leave empty to manage DNS records outside Terraform." +} + +################################################################ +# DNS verification and DKIM +################################################################ + +variable "verify_domain" { + type = bool + default = false + description = "When true, creates a Route53 TXT record for SES domain ownership verification. Requires zone_id to be set." +} + +variable "verify_dkim" { + type = bool + default = false + description = "When true, creates Route53 CNAME records for DKIM signing verification. Requires zone_id to be set." +} + +variable "create_spf_record" { + type = bool + default = false + description = "When true, creates an SPF TXT record in Route53 for the domain. Requires zone_id to be set." +} + +################################################################ +# Custom MAIL FROM domain +################################################################ + +variable "custom_from_subdomain" { + type = list(string) + default = [] + description = "List of subdomains to use as the MAIL FROM (return-path) address. For example, [\"mail\"] sets the MAIL FROM domain to mail.. Required for DMARC alignment when verify_dkim is true." +} + +variable "custom_from_dns_record_enabled" { + type = bool + default = false + description = "When true, creates a Route53 MX record for the custom MAIL FROM subdomain. Only takes effect when custom_from_subdomain is non-empty. Requires zone_id to be set." +} + +variable "custom_from_behavior_on_mx_failure" { + type = string + default = "UseDefaultValue" + description = "The behaviour when the MX record for the custom MAIL FROM domain cannot be found. Valid values: UseDefaultValue (fall back to amazonses.com), RejectMessage (reject the outbound email)." + + validation { + condition = contains(["UseDefaultValue", "RejectMessage"], var.custom_from_behavior_on_mx_failure) + error_message = "custom_from_behavior_on_mx_failure must be either \"UseDefaultValue\" or \"RejectMessage\"." + } +} + +################################################################ +# IAM — sending identity (opt-in; off by default) +################################################################ + +variable "ses_user_enabled" { + type = bool + default = false + description = "When true, creates an IAM user with permission to send emails via SES. Access key and SMTP password are never stored in Terraform state — distribute credentials out-of-band." +} + +variable "ses_group_enabled" { + type = bool + default = false + description = "When true, creates an IAM group with permission to send emails via SES. The group name is derived from context labels via module.this.id." +} + +variable "ses_group_path" { + type = string + default = "/" + description = "The IAM path for the SES IAM group." +} + +variable "iam_permissions" { + type = list(string) + default = ["ses:SendRawEmail"] + description = "List of IAM action strings granted to the SES IAM user or group." +} + +variable "iam_allowed_resources" { + type = list(string) + default = [] + description = "List of resource ARNs that the IAM permissions apply to. Wildcards are accepted. When empty, the policy applies to all resources (`\"*\"`)." +} diff --git a/infrastructure/modules/ses/versions.tf b/infrastructure/modules/ses/versions.tf new file mode 100644 index 00000000..09867bba --- /dev/null +++ b/infrastructure/modules/ses/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + awsutils = { + source = "cloudposse/awsutils" + version = ">= 0.11.0" + } + } +} From fb70b72eec475ba7c6b61679f745447b27b06182 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Mon, 27 Jul 2026 11:15:54 +0100 Subject: [PATCH 2/3] feat(ses): update SES module to disable IAM user/group creation and enhance documentation --- infrastructure/modules/ses/README.md | 21 +++++++---- infrastructure/modules/ses/locals.tf | 6 --- infrastructure/modules/ses/main.tf | 27 ++++++-------- infrastructure/modules/ses/outputs.tf | 14 ------- infrastructure/modules/ses/variables.tf | 49 ++----------------------- 5 files changed, 27 insertions(+), 90 deletions(-) delete mode 100644 infrastructure/modules/ses/locals.tf diff --git a/infrastructure/modules/ses/README.md b/infrastructure/modules/ses/README.md index 03244e4f..68e0e6f4 100644 --- a/infrastructure/modules/ses/README.md +++ b/infrastructure/modules/ses/README.md @@ -8,6 +8,9 @@ Thin NHS wrapper around [cloudposse/ses/aws](https://registry.terraform.io/modul | --- | --- | --- | | `iam_create_access_key` | `false` | IAM access keys must never be stored in Terraform state | | `iam_create_ses_smtp_password` | `false` | SMTP passwords must never be stored in Terraform state | +| `ses_user_enabled` | `false` | ECS tasks authenticate via IAM roles, not IAM users | +| `ses_group_enabled` | `false` | ECS tasks authenticate via IAM roles, not IAM groups | +| `custom_from_behavior_on_mx_failure` | `UseDefaultValue` | Sensible default; falls back to amazonses.com on MX failure | ## Provider requirements @@ -67,7 +70,7 @@ module "ses" { } ``` -### With IAM group for sending +### Domain identity with custom MAIL FROM ```hcl module "ses" { @@ -76,29 +79,31 @@ module "ses" { context = module.this.context name = "ses" - domain = "example.nhs.uk" - zone_id = module.r53.zone_id + domain = "${var.environment}.bcss.nhs.uk" + zone_id = module.r53.hosted_zone_ids["public"] verify_domain = true verify_dkim = true - ses_group_enabled = true + create_spf_record = true + + custom_from_subdomain = ["mail"] + custom_from_dns_record_enabled = true } ``` ## Conventions * The SES domain identity name is the `domain` value itself; it is not derived from context labels. -* The IAM group name is always derived from `module.this.id` (context labels) to align with NHS naming conventions. * `verify_domain`, `verify_dkim`, `create_spf_record`, and `custom_from_dns_record_enabled` all require `zone_id` to be set — the module will fail at plan time if they are enabled without one. -* `ses_user_enabled` and `ses_group_enabled` both default to `false`; opt in explicitly when a sending identity is required. -* IAM access keys and SMTP passwords are hardcoded to never be stored in Terraform state — distribute credentials out-of-band via the AWS console or CLI after creation. +* IAM access keys and SMTP passwords are hardcoded to never be stored in Terraform state. +* IAM user and group creation are hardcoded off. Grant SES sending permissions to ECS task IAM roles directly via `ses:SendRawEmail` on the domain identity ARN. * Every AWS account starts in SES Sandbox mode. Sending to unverified addresses requires a production access request via AWS Support. ## What this module does NOT do * Move the account out of SES Sandbox mode — raise an AWS Support request to enable production sending. * Create or manage Route53 hosted zones — provide an existing zone ID via `zone_id`. -* Store IAM credentials — access keys and SMTP passwords must be retrieved and distributed out-of-band. +* Create IAM users or groups — grant `ses:SendRawEmail` on the domain identity ARN directly to ECS task roles. * Configure SES sending quotas, suppression lists, or configuration sets — manage those resources separately. * Create an SES email identity for individual addresses — this module handles domain identities only. diff --git a/infrastructure/modules/ses/locals.tf b/infrastructure/modules/ses/locals.tf deleted file mode 100644 index f05a2b5c..00000000 --- a/infrastructure/modules/ses/locals.tf +++ /dev/null @@ -1,6 +0,0 @@ -locals { - # Override the cloudposse IAM group name to follow NHS context-based naming conventions. - # Without this, cloudposse's label module would generate its own name - # that does not align with the NHS screening platform's standard. - ses_group_name = module.this.id -} diff --git a/infrastructure/modules/ses/main.tf b/infrastructure/modules/ses/main.tf index 03cfd710..ab548a22 100644 --- a/infrastructure/modules/ses/main.tf +++ b/infrastructure/modules/ses/main.tf @@ -6,20 +6,22 @@ # # * IAM credentials: access keys never written to Terraform state # * SMTP passwords: never written to Terraform state -# * IAM group name: derived from context labels via module.this.id +# * IAM user/group: always disabled; ECS tasks use IAM roles instead # * Tagging: all NHS-required tags applied automatically # * Enabled flag: create = module.this.enabled # # Inputs intentionally NOT exposed (hardcoded below): # - iam_create_access_key → always false; credentials must not be stored in state # - iam_create_ses_smtp_password → always false; credentials must not be stored in state -# - ses_group_name → derived from module.this.id via locals.tf +# - ses_user_enabled → always false; use ECS task IAM roles for sending +# - ses_group_enabled → always false; use ECS task IAM roles for sending +# - custom_from_behavior_on_mx_failure → UseDefaultValue (sensible default, not exposed) # # Cross-variable input constraints are enforced in validations.tf. # # NOTE: This module requires the cloudposse/awsutils provider to be -# configured in the calling stack, even when ses_user_enabled = false, -# because the cloudposse/ses upstream module declares it as a required provider. +# configured in the calling stack because the cloudposse/ses upstream +# module declares it as a required provider. ################################################################ module "ses" { @@ -38,21 +40,14 @@ module "ses" { create_spf_record = var.create_spf_record # Custom MAIL FROM domain - custom_from_subdomain = var.custom_from_subdomain - custom_from_dns_record_enabled = var.custom_from_dns_record_enabled - custom_from_behavior_on_mx_failure = var.custom_from_behavior_on_mx_failure + custom_from_subdomain = var.custom_from_subdomain + custom_from_dns_record_enabled = var.custom_from_dns_record_enabled - # IAM — opt-in; off by default - ses_user_enabled = var.ses_user_enabled - ses_group_enabled = var.ses_group_enabled - ses_group_name = local.ses_group_name - ses_group_path = var.ses_group_path - - iam_permissions = var.iam_permissions - iam_allowed_resources = var.iam_allowed_resources + # IAM — hardcoded off; ECS services authenticate via task IAM roles, not IAM users. + ses_user_enabled = false # hardcoded — use ECS task roles for sending permissions + ses_group_enabled = false # hardcoded — use ECS task roles for sending permissions # Security baseline: IAM credentials must never be stored in Terraform state. - # Rotate and distribute credentials out-of-band (e.g. via the AWS console or CLI). iam_create_access_key = false # hardcoded — access keys in state are prohibited iam_create_ses_smtp_password = false # hardcoded — SMTP passwords in state are prohibited diff --git a/infrastructure/modules/ses/outputs.tf b/infrastructure/modules/ses/outputs.tf index c35a2eaf..1266b53b 100644 --- a/infrastructure/modules/ses/outputs.tf +++ b/infrastructure/modules/ses/outputs.tf @@ -23,17 +23,3 @@ output "custom_from_domain" { value = module.ses.custom_from_domain } -output "user_arn" { - description = "The ARN of the IAM user created for SES sending. Empty when ses_user_enabled is false." - value = module.ses.user_arn -} - -output "user_name" { - description = "The name of the IAM user created for SES sending. Empty when ses_user_enabled is false." - value = module.ses.user_name -} - -output "ses_group_name" { - description = "The name of the IAM group created for SES sending. Empty when ses_group_enabled is false." - value = module.ses.ses_group_name -} diff --git a/infrastructure/modules/ses/variables.tf b/infrastructure/modules/ses/variables.tf index 1aeaf54d..6869e709 100644 --- a/infrastructure/modules/ses/variables.tf +++ b/infrastructure/modules/ses/variables.tf @@ -7,7 +7,9 @@ # Inputs NOT exposed here (opinionated defaults hardcoded in main.tf): # - iam_create_access_key → always false (credentials must not be stored in state) # - iam_create_ses_smtp_password → always false (credentials must not be stored in state) -# - ses_group_name → derived from module.this.id via locals.tf +# - ses_user_enabled → always false (ECS tasks use IAM roles, not IAM users) +# - ses_group_enabled → always false (ECS tasks use IAM roles, not IAM users) +# - custom_from_behavior_on_mx_failure → UseDefaultValue (sensible default, not exposed) ################################################################ ################################################################ @@ -67,48 +69,3 @@ variable "custom_from_dns_record_enabled" { default = false description = "When true, creates a Route53 MX record for the custom MAIL FROM subdomain. Only takes effect when custom_from_subdomain is non-empty. Requires zone_id to be set." } - -variable "custom_from_behavior_on_mx_failure" { - type = string - default = "UseDefaultValue" - description = "The behaviour when the MX record for the custom MAIL FROM domain cannot be found. Valid values: UseDefaultValue (fall back to amazonses.com), RejectMessage (reject the outbound email)." - - validation { - condition = contains(["UseDefaultValue", "RejectMessage"], var.custom_from_behavior_on_mx_failure) - error_message = "custom_from_behavior_on_mx_failure must be either \"UseDefaultValue\" or \"RejectMessage\"." - } -} - -################################################################ -# IAM — sending identity (opt-in; off by default) -################################################################ - -variable "ses_user_enabled" { - type = bool - default = false - description = "When true, creates an IAM user with permission to send emails via SES. Access key and SMTP password are never stored in Terraform state — distribute credentials out-of-band." -} - -variable "ses_group_enabled" { - type = bool - default = false - description = "When true, creates an IAM group with permission to send emails via SES. The group name is derived from context labels via module.this.id." -} - -variable "ses_group_path" { - type = string - default = "/" - description = "The IAM path for the SES IAM group." -} - -variable "iam_permissions" { - type = list(string) - default = ["ses:SendRawEmail"] - description = "List of IAM action strings granted to the SES IAM user or group." -} - -variable "iam_allowed_resources" { - type = list(string) - default = [] - description = "List of resource ARNs that the IAM permissions apply to. Wildcards are accepted. When empty, the policy applies to all resources (`\"*\"`)." -} From 3689a3537a1a95b7a7861e3bfd25fb42130f64f2 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Mon, 27 Jul 2026 13:50:45 +0100 Subject: [PATCH 3/3] feat(ses): remove unused output for custom MAIL FROM domain in SES module --- infrastructure/modules/ses/README.md | 9 --------- infrastructure/modules/ses/outputs.tf | 1 - 2 files changed, 10 deletions(-) diff --git a/infrastructure/modules/ses/README.md b/infrastructure/modules/ses/README.md index 68e0e6f4..ab427d9a 100644 --- a/infrastructure/modules/ses/README.md +++ b/infrastructure/modules/ses/README.md @@ -153,7 +153,6 @@ The following constraints are enforced at `plan` time via preconditions in `vali | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | | [create\_spf\_record](#input\_create\_spf\_record) | When true, creates an SPF TXT record in Route53 for the domain. Requires zone\_id to be set. | `bool` | `false` | no | -| [custom\_from\_behavior\_on\_mx\_failure](#input\_custom\_from\_behavior\_on\_mx\_failure) | The behaviour when the MX record for the custom MAIL FROM domain cannot be found. Valid values: UseDefaultValue (fall back to amazonses.com), RejectMessage (reject the outbound email). | `string` | `"UseDefaultValue"` | no | | [custom\_from\_dns\_record\_enabled](#input\_custom\_from\_dns\_record\_enabled) | When true, creates a Route53 MX record for the custom MAIL FROM subdomain. Only takes effect when custom\_from\_subdomain is non-empty. Requires zone\_id to be set. | `bool` | `false` | no | | [custom\_from\_subdomain](#input\_custom\_from\_subdomain) | List of subdomains to use as the MAIL FROM (return-path) address. For example, ["mail"] sets the MAIL FROM domain to mail.. Required for DMARC alignment when verify\_dkim is true. | `list(string)` | `[]` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | @@ -163,8 +162,6 @@ The following constraints are enforced at `plan` time via preconditions in `vali | [domain](#input\_domain) | The domain to create the SES identity for, e.g. "example.nhs.uk". | `string` | n/a | yes | | [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 | -| [iam\_allowed\_resources](#input\_iam\_allowed\_resources) | List of resource ARNs that the IAM permissions apply to. Wildcards are accepted. When empty, the policy applies to all resources (`"*"`). | `list(string)` | `[]` | no | -| [iam\_permissions](#input\_iam\_permissions) | List of IAM action strings granted to the SES IAM user or group. | `list(string)` |
[
"ses:SendRawEmail"
]
| 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 | @@ -179,9 +176,6 @@ The following constraints are enforced at `plan` time via preconditions in `vali | [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 | -| [ses\_group\_enabled](#input\_ses\_group\_enabled) | When true, creates an IAM group with permission to send emails via SES. The group name is derived from context labels via module.this.id. | `bool` | `false` | no | -| [ses\_group\_path](#input\_ses\_group\_path) | The IAM path for the SES IAM group. | `string` | `"/"` | no | -| [ses\_user\_enabled](#input\_ses\_user\_enabled) | When true, creates an IAM user with permission to send emails via SES. Access key and SMTP password are never stored in Terraform state — distribute credentials out-of-band. | `bool` | `false` | no | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | @@ -200,8 +194,5 @@ The following constraints are enforced at `plan` time via preconditions in `vali | [ses\_dkim\_tokens](#output\_ses\_dkim\_tokens) | List of DKIM tokens to add as CNAME records in your DNS zone to enable DKIM signing. Only required when zone\_id is not provided and verify\_dkim is managed externally. | | [ses\_domain\_identity\_arn](#output\_ses\_domain\_identity\_arn) | The ARN of the SES domain identity. | | [ses\_domain\_identity\_verification\_token](#output\_ses\_domain\_identity\_verification\_token) | The TXT record value to add to your DNS zone to verify SES domain ownership. Only required when zone\_id is not provided and verify\_domain is managed externally. | -| [ses\_group\_name](#output\_ses\_group\_name) | The name of the IAM group created for SES sending. Empty when ses\_group\_enabled is false. | | [spf\_record](#output\_spf\_record) | The SPF TXT record value. Add this to your DNS zone when create\_spf\_record is false and you manage DNS records externally. | -| [user\_arn](#output\_user\_arn) | The ARN of the IAM user created for SES sending. Empty when ses\_user\_enabled is false. | -| [user\_name](#output\_user\_name) | The name of the IAM user created for SES sending. Empty when ses\_user\_enabled is false. | diff --git a/infrastructure/modules/ses/outputs.tf b/infrastructure/modules/ses/outputs.tf index 1266b53b..eb183d06 100644 --- a/infrastructure/modules/ses/outputs.tf +++ b/infrastructure/modules/ses/outputs.tf @@ -22,4 +22,3 @@ output "custom_from_domain" { description = "The custom MAIL FROM domain (e.g. mail.example.nhs.uk). Empty when custom_from_subdomain is not set." value = module.ses.custom_from_domain } -