diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 6b252b9e..a49e7c15 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -78,6 +78,7 @@ updates: - "infrastructure/modules/ses" - "infrastructure/modules/sns" - "infrastructure/modules/sqs" + - "infrastructure/modules/ssm-parameter-wo" - "infrastructure/modules/ssm-parameter" - "infrastructure/modules/ssm-patch-manager" - "infrastructure/modules/tags" diff --git a/README.md b/README.md index ff744ad4..22d1adc1 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,7 @@ Rules: | `sns` | terraform-aws-modules/sns/aws | SNS topic with encryption and policies | | `sqs` | — | SQS queue with encryption | | `ssm-parameter` | — | — | +| `ssm-parameter-wo` | Native resources | SSM parameter with write-only, ephemeral-capable SecureString values (never stored in state) | | `ssm-patch-manager` | cloudposse/ssm-patch-manager/aws | SSM Patch Manager for automated OS patching | | `tags` | — | Foundation: naming and tagging context module | | `vpc` | terraform-aws-modules/vpc/aws | VPC with subnets, routing, and gateways | diff --git a/docs/adr/ADR-006_Native_write_only_SSM_parameter_module_for_ephemeral_secrets.md b/docs/adr/ADR-006_Native_write_only_SSM_parameter_module_for_ephemeral_secrets.md new file mode 100644 index 00000000..32a9cec8 --- /dev/null +++ b/docs/adr/ADR-006_Native_write_only_SSM_parameter_module_for_ephemeral_secrets.md @@ -0,0 +1,98 @@ +# ADR-006: Native write-only SSM parameter module for ephemeral secrets + +>| | | +>| ------------ | ------------------------------------------------ | +>| Date | `30/09/2026` | +>| Status | `Proposed` | +>| Deciders | `Engineering` | +>| Significance | `Security, Construction techniques, Interfaces` | +>| Owners | | + +--- + +- [ADR-006: Native write-only SSM parameter module for ephemeral secrets](#adr-006-native-write-only-ssm-parameter-module-for-ephemeral-secrets) + - [Context](#context) + - [Decision](#decision) + - [Assumptions](#assumptions) + - [Drivers](#drivers) + - [Options](#options) + - [Outcome](#outcome) + - [Rationale](#rationale) + - [Consequences](#consequences) + - [Compliance](#compliance) + - [Notes](#notes) + - [Actions](#actions) + - [Tags](#tags) + +## Context + +The `ssm-parameter` module wraps `terraform-aws-modules/ssm-parameter/aws` (v2.1.0). +For `SecureString` it uses the provider's write-only `value_wo` argument, so the value is not stored in state. +However, it is only half of the solution: + +- The community module declares `value` as a normal input variable. Terraform only lets ephemeral values (from `ephemeral` resources or `ephemeral = true` variables) reach a write-only argument if **every** module layer declares the input as ephemeral. Consumers therefore have to read secrets with non-ephemeral data sources, which **do** store decrypted values in state and plan files. +- `value_wo_version` silently defaults to `1`. Consumers who don't know this never see updated values applied. The workaround in one consumer (`NHSDigital/bcss`) was to derive the version from a SHA-256 of the secret, which puts a fingerprint of each secret into state. + +Downstream consumers (for example `NHSDigital/bcss` BCSS-9997) need to copy secrets between SSM paths without Terraform ever storing the plaintext. + +## Decision + +### Assumptions + +- Terraform `>= 1.13` and AWS provider `>= 6.28` are the repository baseline (write-only attributes need Terraform 1.11+; `ephemeral "aws_ssm_parameter"` is available in AWS provider 6.x). +- The community module may change in future; we should not fork it or depend on its internals. + +### Drivers + +- No secret values or secret-derived hashes in state or plan files. +- Accept ephemeral values end-to-end. +- Fail fast and explicitly rather than silently ignoring value changes. +- Painless migration for existing `ssm-parameter` callers. + +### Options + +1. **Modify `ssm-parameter` in place.** It would still depend on the community module's non-ephemeral `value` variable, so this doesn't meet the drivers without forking upstream. +2. **Fork `terraform-aws-modules/ssm-parameter`.** We would own a copy of a third-party module and diverge from upstream fixes. Higher maintenance. +3. **New native module `ssm-parameter-wo` declaring `aws_ssm_parameter` directly (selected).** It keeps the same interface and outputs as `ssm-parameter`, adds an ephemeral `value_wo` input, makes `value_wo_version` mandatory for `SecureString`, and includes `moved` blocks for in-place migration. + +### Outcome + +Option 3. `ssm-parameter` stays unchanged apart from a validation bug fix, for callers that don't need ephemeral inputs. This decision is reversible: if the community module adds ephemeral input support, `ssm-parameter-wo` can be deprecated in its favour. + +### Rationale + +| Criterion | 1. Modify in place | 2. Fork upstream | 3. New native module | +| --- | --- | --- | --- | +| Ephemeral values end-to-end | ❌ | ✅ | ✅ | +| No hash fingerprints needed | ❌ | ✅ | ✅ | +| No third-party code to maintain | ✅ | ❌ | ✅ | +| Existing callers unaffected | ⚠️ | ✅ | ✅ | +| In-place migration | n/a | ⚠️ | ✅ (`moved` blocks) | +| Effort | S | L | M | + +## Consequences + +- Two SSM parameter modules exist. The READMEs explain when to use each. +- Callers switching to `ssm-parameter-wo` must supply `value_wo_version` for `SecureString`, and should switch from `value` to `value_wo`. +- `SecureString` outputs (`value`, `raw_value`, `secure_value`) are always `null`. +- Cross-variable rules are variable validations rather than `validations.tf` preconditions, because the provider's own argument checks run before preconditions. +- `terraform test` suites use `command = plan`, because the test framework cannot re-supply ephemeral inputs to the apply phase. + +## Compliance + +- `make terraform-test module=ssm-parameter-wo` passes. +- In consumers, `terraform state pull` and `terraform show -json ` contain no plaintext for parameters managed by this module (verified with a test value that should appear 0 times). + +## Notes + +- Module: [`infrastructure/modules/ssm-parameter-wo`](../../infrastructure/modules/ssm-parameter-wo) +- Terraform docs: ephemeral values and write-only arguments. +- Consumer decision: `NHSDigital/bcss` `infrastructure_v2/docs/adr/ADR-002`. + +## Actions + +- [ ] Engineering — raise a Jira ticket to review deprecating `ssm-parameter-wo` if the community module adds ephemeral input support. + +## Tags + +`#security #data #maintainability #simplicity` diff --git a/infrastructure/modules/ssm-parameter-wo/.terraform.lock.hcl b/infrastructure/modules/ssm-parameter-wo/.terraform.lock.hcl new file mode 100644 index 00000000..8e83c35b --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/.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.66.0" + constraints = ">= 6.14.0, >= 6.28.0" + hashes = [ + "h1:5t1vkYwqDYRN27RliDkyWRmQfQCnNHFqWxC2UDVCK78=", + "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "h1:hBEaeBm9nm7A/u1nnD0nfolTPP55/BoKRFWk8zG8/fk=", + "h1:mIolsCn33slp3F7Zd4KCTScXAWuUQsjtIzA/a6TFG6Q=", + "h1:xehZnyesOrJ1/R9tmnRSu7FRkwoDDKelEHI3WdnJ72g=", + "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", + "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", + "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", + "zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd", + "zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740", + "zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706", + "zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4", + "zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd", + "zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230", + "zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb", + "zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632", + "zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb", + "zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f", + "zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283", + ] +} diff --git a/infrastructure/modules/ssm-parameter-wo/README.md b/infrastructure/modules/ssm-parameter-wo/README.md new file mode 100644 index 00000000..8a6777e5 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/README.md @@ -0,0 +1,236 @@ +# SSM Parameter (write-only) + +Native NHS module for `aws_ssm_parameter` that keeps `SecureString` values out of Terraform state and plan files. +It sends secrets through the provider's write-only `value_wo` argument and accepts **ephemeral** values, such as those from `ephemeral` resources. + +Use this module instead of [`ssm-parameter`](../ssm-parameter) when a secret must never be persisted by Terraform. +It declares `aws_ssm_parameter` directly because the community `terraform-aws-modules/ssm-parameter` module declares its `value` input as a normal (non-ephemeral) variable, so ephemeral values cannot pass through it. + +## What this module enforces + +| Control | How it is enforced | Impact | +| --- | --- | --- | +| Naming consistency | Parameter name is derived from context labels; when `delimiter = "/"`, names are path-style and start with `/` | Ensures hierarchical organisation across teams | +| Tagging consistency | All tags sourced from `module.ssm_param_label.tags` (NHS-standard set) | Ensures billing, compliance, and governance controls | +| KMS encryption for secrets | `key_id` is **mandatory** when `type = "SecureString"` | No unencrypted secrets in SSM | +| Secrets never stored by Terraform | `SecureString` values are only sent via write-only `value_wo`; `value_wo` is `ephemeral = true` | No secret values in state or plan files | +| Explicit update trigger | `value_wo_version` is **mandatory** for `SecureString` (no silent default) | A changed value is never silently ignored | +| Creation gating | Resources gated on `module.ssm_param_label.enabled` | Allows disabling entire module via context | + +## Usage + +### 1. SecureString from an ephemeral source (recommended) + +The value is read ephemerally and written write-only, so it never reaches state or plan files. +Use a **non-secret** version number, such as the source parameter's SSM version, as the update trigger. + +```hcl +data "aws_ssm_parameter" "source_meta" { + name = "/bcss/test/shared/default/exports/NOTIFY_API_KEY" + with_decryption = false # only the version is needed here +} + +ephemeral "aws_ssm_parameter" "source" { + arn = data.aws_ssm_parameter.source_meta.arn +} + +module "notify_api_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter-wo?ref=" + + context = module.this.context + delimiter = "/" + label_value_case = "none" + name = "NOTIFY_API_KEY" + + type = "SecureString" + key_id = module.kms.key_arn + value_wo = ephemeral.aws_ssm_parameter.source.value + value_wo_version = data.aws_ssm_parameter.source_meta.version +} +``` + +### 2. Console-managed SecureString (Terraform seeds once, never overwrites) + +```hcl +module "console_managed_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter-wo?ref=" + + context = module.this.context + name = "third-party-api-key" + + type = "SecureString" + key_id = module.kms.key_arn + value_wo = "CHANGE_ME" + value_wo_version = 1 + ignore_value_changes = true +} +``` + +### 3. Plain String parameter + +```hcl +module "log_level" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter-wo?ref=" + + context = module.this.context + name = "log-level" + + type = "String" + value = "INFO" +} +``` + +### 4. StringList parameter + +```hcl +module "allowed_ips" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter-wo?ref=" + + context = module.this.context + name = "allowed-ips" + + type = "StringList" + values = ["10.0.0.0/8", "192.168.0.0/16"] # JSON-encoded before storage +} +``` + +## Migrating from `ssm-parameter` + +The module includes `moved` blocks from `module.ssm_parameter.aws_ssm_parameter.{this,ignore_value}`. +Change a caller's `source` from `ssm-parameter` to `ssm-parameter-wo` and keep the module call name. +Existing parameters then move in place on the next plan, with no destroy/create and no `terraform state mv`. + +For `SecureString` callers you must also provide `value_wo_version`. `value` still works, but switch to `value_wo` to accept ephemeral values. + +## Conventions + +- **Type selection:** + - `String` / `StringList` — non-secret configuration. Stored in state as `insecure_value` and exposed via the `value` output. + - `SecureString` — secrets. **Requires `key_id` and `value_wo_version`.** The value is never stored in state or plan files, and the `value`, `raw_value` and `secure_value` outputs are always `null`. +- **Choosing `value_wo_version`:** use a number that changes when the secret changes but reveals nothing about it, such as the source SSM parameter's `version` or a manually bumped revision. Do **not** derive it from a hash of the secret. +- **Console or rotation-managed values:** set `ignore_value_changes = true`. Terraform seeds the value on creation and never overwrites it. Toggling this flag replaces the resource, which re-seeds the value. The module always destroys the old parameter before creating the new one, so it never deletes a freshly created parameter. If an apply fails midway through a toggle, check the next plan: it must not show a destroy for a parameter with the same name. +- **Naming:** Parameter names are derived from context labels. When `delimiter = "/"`, names are path-style with a leading `/`. Override with `parameter_name` if custom naming is required. + +## What this module does NOT do + +- **Read or decrypt secrets:** callers supply values. Use `ephemeral "aws_ssm_parameter"` or other ephemeral sources to avoid state exposure. +- **Hash values into versions:** `value_wo_version` must be supplied by the caller. +- **Create KMS keys or IAM permissions:** provide an existing key via `key_id`; attach access policies separately. +- **Rotate secrets:** use `ignore_value_changes = true` when rotation automation owns the value. +- **Share parameters across AWS accounts.** + +## Validation + +All rules are variable validations, so they fail before the provider's own argument checks run: + +- `type` must be `String`, `StringList` or `SecureString` +- `SecureString` requires `key_id`, `value_wo_version`, and either `value_wo` (preferred) or `value` +- `value_wo` and `value_wo_version` are only valid for `SecureString` +- `values` is only valid for `StringList` +- `value`, `values` and `value_wo` are mutually exclusive +- `String` / `StringList` require `value` or `values` +- `parameter_name`, when set, must start with `/` + +## Testing + +```bash +make terraform-test module=ssm-parameter-wo +``` + +Tests use `mock_provider` and `command = plan`, because `terraform test` cannot re-supply ephemeral inputs to the apply phase. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.28 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.66.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ssm\_param\_label](#module\_ssm\_param\_label) | ../tags | n/a | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_ssm_parameter.ignore_value](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | +| [aws_ssm_parameter.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | 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 | +| [allowed\_pattern](#input\_allowed\_pattern) | Regular expression used to validate the parameter value | `string` | `null` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [description](#input\_description) | Description of the parameter | `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 | +| [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 | +| [ignore\_value\_changes](#input\_ignore\_value\_changes) | Whether to create the SSM parameter and then ignore changes to its value, e.g. when the value is managed in the console or by rotation automation | `bool` | `false` | no | +| [key\_id](#input\_key\_id) | KMS key ID or ARN for encrypting a `SecureString` | `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 | +| [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 | +| [overwrite](#input\_overwrite) | Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform | `bool` | `null` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [parameter\_name](#input\_parameter\_name) | Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`. | `string` | `null` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [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 | +| [ssm\_data\_type](#input\_ssm\_data\_type) | Data type of the parameter. Valid values: `text`, `aws:ssm:integration` and `aws:ec2:image` for AMI format, see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-ec2-aliases.html | `string` | `null` | 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 | +| [tier](#input\_tier) | Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [type](#input\_type) | Type of the parameter. Valid types are `String`, `StringList` and `SecureString` | `string` | n/a | yes | +| [value](#input\_value) | Value of the parameter. For `String`/`StringList` it is stored in state as `insecure_value`. For `SecureString` it is sent via the write-only `value_wo` argument and never stored; prefer `value_wo` so ephemeral values can be used. | `string` | `null` | no | +| [value\_wo](#input\_value\_wo) | Write-only value for a `SecureString` parameter. Accepts ephemeral values and is never stored in state or plan files. Requires `value_wo_version`. | `string` | `null` | no | +| [value\_wo\_version](#input\_value\_wo\_version) | Version trigger for the write-only SecureString value. Required for `SecureString`; Terraform only re-sends the value when this number changes. | `number` | `null` | no | +| [values](#input\_values) | List of values for a `StringList` parameter (JSON-encoded before being stored). | `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 | +| ---- | ----------- | +| [insecure\_value](#output\_insecure\_value) | Value of a `String`/`StringList` parameter. Always null for `SecureString`. | +| [raw\_value](#output\_raw\_value) | Raw stored value of a `String`/`StringList` parameter. Always null for `SecureString`. Use `value` for the JSON-decoded value. | +| [secure\_type](#output\_secure\_type) | Whether the SSM parameter is a SecureString | +| [secure\_value](#output\_secure\_value) | Always null: SecureString values are write-only and never read back into state. | +| [ssm\_parameter\_arn](#output\_ssm\_parameter\_arn) | The ARN of the parameter | +| [ssm\_parameter\_name](#output\_ssm\_parameter\_name) | Name of the parameter | +| [ssm\_parameter\_type](#output\_ssm\_parameter\_type) | Type of the parameter | +| [ssm\_parameter\_version](#output\_ssm\_parameter\_version) | Version of the parameter | +| [value](#output\_value) | Value of a `String`/`StringList` parameter after jsondecode() where possible. Always null for `SecureString`. | + + + diff --git a/infrastructure/modules/ssm-parameter-wo/context.tf b/infrastructure/modules/ssm-parameter-wo/context.tf new file mode 100644 index 00000000..e934a84f --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/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/ssm-parameter-wo/locals.tf b/infrastructure/modules/ssm-parameter-wo/locals.tf new file mode 100644 index 00000000..0aba5284 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/locals.tf @@ -0,0 +1,15 @@ +locals { + # When delimiter is "/", prepend "/" to ensure path-style parameter names (AWS SSM standard) + parameter_name = var.parameter_name != null ? var.parameter_name : (module.ssm_param_label.delimiter == "/" ? format("/%s", module.ssm_param_label.id) : module.ssm_param_label.id) + + secure_type = var.type == "SecureString" + + # StringList values are JSON-encoded, matching the ssm-parameter module behaviour + plain_value = local.secure_type ? null : (var.type == "StringList" && length(var.values) > 0 ? jsonencode(var.values) : var.value) + + # String/StringList values are not secrets by definition; SecureString never populates insecure_value + insecure_value = one(compact([ + try(nonsensitive(aws_ssm_parameter.this[0].insecure_value), null), + try(nonsensitive(aws_ssm_parameter.ignore_value[0].insecure_value), null), + ])) +} diff --git a/infrastructure/modules/ssm-parameter-wo/main.tf b/infrastructure/modules/ssm-parameter-wo/main.tf new file mode 100644 index 00000000..1c46f438 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/main.tf @@ -0,0 +1,96 @@ +################################################################ +# SSM Parameter (write-only) +# +# Native NHS module for aws_ssm_parameter that enforces the +# screening platform's baseline controls: +# +# * Naming: derived from context labels via module.ssm_param_label.id +# * Tagging: all NHS-required tags applied via module.ssm_param_label.tags +# * SecureString: KMS key_id is mandatory; values are only ever sent via the +# write-only value_wo argument, so they never reach state or plan +# * Ephemeral: value_wo accepts ephemeral values (e.g. ephemeral resources) +# * Versioning: value_wo_version is mandatory for SecureString (no silent default) +# * Enabled flag: resources gated on module.ssm_param_label.enabled +# +# Declares aws_ssm_parameter directly rather than wrapping +# terraform-aws-modules/ssm-parameter, whose non-ephemeral value +# input cannot accept ephemeral values. +# +# Cross-variable input constraints are enforced by variable +# validations in variables.tf. +################################################################ + +module "ssm_param_label" { + source = "../tags" + + # Allow forward slashes for hierarchical parameter names + regex_replace_chars = "/[^a-zA-Z0-9-_\\/]/" + + context = module.this.context +} + +resource "aws_ssm_parameter" "this" { + count = module.ssm_param_label.enabled && !var.ignore_value_changes ? 1 : 0 + + name = local.parameter_name + type = var.type + description = var.description + tier = var.tier + data_type = var.ssm_data_type + allowed_pattern = var.allowed_pattern + overwrite = var.overwrite + key_id = local.secure_type ? var.key_id : null + + insecure_value = local.plain_value + value_wo = local.secure_type ? (var.value_wo != null ? var.value_wo : var.value) : null + value_wo_version = local.secure_type ? var.value_wo_version : null + + tags = module.ssm_param_label.tags +} + +resource "aws_ssm_parameter" "ignore_value" { + count = module.ssm_param_label.enabled && var.ignore_value_changes ? 1 : 0 + + # Both resources share one parameter name: this edge makes Terraform destroy the old + # address before creating the new one when ignore_value_changes is toggled (either way). + depends_on = [aws_ssm_parameter.this] + + name = local.parameter_name + type = var.type + description = var.description + tier = var.tier + data_type = var.ssm_data_type + allowed_pattern = var.allowed_pattern + overwrite = var.overwrite + key_id = local.secure_type ? var.key_id : null + + insecure_value = local.plain_value + value_wo = local.secure_type ? (var.value_wo != null ? var.value_wo : var.value) : null + value_wo_version = local.secure_type ? var.value_wo_version : null + + tags = module.ssm_param_label.tags + + lifecycle { + ignore_changes = [ + insecure_value, + value, + value_wo, + value_wo_version, + ] + } +} + +################################################################ +# Drop-in replacement for the ssm-parameter module: existing state +# moves in place when a caller switches its module source. +################################################################ + +moved { + from = module.ssm_parameter.aws_ssm_parameter.this + to = aws_ssm_parameter.this +} + +moved { + from = module.ssm_parameter.aws_ssm_parameter.ignore_value + to = aws_ssm_parameter.ignore_value +} diff --git a/infrastructure/modules/ssm-parameter-wo/outputs.tf b/infrastructure/modules/ssm-parameter-wo/outputs.tf new file mode 100644 index 00000000..6449c5c6 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/outputs.tf @@ -0,0 +1,46 @@ +output "insecure_value" { + description = "Value of a `String`/`StringList` parameter. Always null for `SecureString`." + value = local.insecure_value +} + +output "raw_value" { + description = "Raw stored value of a `String`/`StringList` parameter. Always null for `SecureString`. Use `value` for the JSON-decoded value." + value = local.insecure_value + sensitive = true +} + +output "secure_type" { + description = "Whether the SSM parameter is a SecureString" + value = local.secure_type +} + +output "secure_value" { + description = "Always null: SecureString values are write-only and never read back into state." + value = null + sensitive = true +} + +output "ssm_parameter_arn" { + description = "The ARN of the parameter" + value = try(aws_ssm_parameter.this[0].arn, aws_ssm_parameter.ignore_value[0].arn, null) +} + +output "ssm_parameter_name" { + description = "Name of the parameter" + value = try(aws_ssm_parameter.this[0].name, aws_ssm_parameter.ignore_value[0].name, null) +} + +output "ssm_parameter_type" { + description = "Type of the parameter" + value = try(aws_ssm_parameter.this[0].type, aws_ssm_parameter.ignore_value[0].type, null) +} + +output "ssm_parameter_version" { + description = "Version of the parameter" + value = try(aws_ssm_parameter.this[0].version, aws_ssm_parameter.ignore_value[0].version, null) +} + +output "value" { + description = "Value of a `String`/`StringList` parameter after jsondecode() where possible. Always null for `SecureString`." + value = try(jsondecode(local.insecure_value), local.insecure_value) +} diff --git a/infrastructure/modules/ssm-parameter-wo/tests/ssm_parameter_wo.tftest.hcl b/infrastructure/modules/ssm-parameter-wo/tests/ssm_parameter_wo.tftest.hcl new file mode 100644 index 00000000..70933c04 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/tests/ssm_parameter_wo.tftest.hcl @@ -0,0 +1,235 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + arn = "arn:aws:iam::111111111111:role/mock" + } + } + + mock_data "aws_iam_session_context" { + defaults = { + issuer_arn = "arn:aws:iam::111111111111:role/mock" + } + } + + mock_resource "aws_ssm_parameter" { + defaults = { + arn = "arn:aws:ssm:eu-west-2:111111111111:parameter/mock" + version = 1 + } + } +} + +variables { + service = "bcss" + environment = "test" + stack = "application" + delimiter = "/" + name = "MY_KEY" + + label_value_case = "none" + label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] +} + +# All runs use plan: terraform test cannot re-supply ephemeral inputs to the apply phase. + +run "string_parameter" { + command = plan + + variables { + type = "String" + value = "plain-value" + } + + assert { + condition = aws_ssm_parameter.this[0].name == "/bcss/test/application/default/MY_KEY" + error_message = "Path-style name should be derived from context labels with case preserved." + } + + assert { + condition = aws_ssm_parameter.this[0].insecure_value == "plain-value" + error_message = "String value should be stored as insecure_value." + } + + assert { + condition = aws_ssm_parameter.this[0].value_wo_version == null + error_message = "String parameters must not use write-only arguments." + } + + assert { + condition = output.value == "plain-value" + error_message = "value output should expose the String value." + } +} + +run "string_list_parameter" { + command = plan + + variables { + type = "StringList" + values = ["a", "b"] + } + + assert { + condition = aws_ssm_parameter.this[0].insecure_value == jsonencode(["a", "b"]) + error_message = "StringList values should be JSON-encoded." + } +} + +run "secure_string_ephemeral_value" { + command = plan + + variables { + type = "SecureString" + key_id = "arn:aws:kms:eu-west-2:111111111111:key/mock" + value_wo = "super-secret" + value_wo_version = 3 + } + + assert { + condition = aws_ssm_parameter.this[0].type == "SecureString" + error_message = "Parameter type should be SecureString." + } + + assert { + condition = aws_ssm_parameter.this[0].value_wo_version == 3 && aws_ssm_parameter.this[0].key_id == var.key_id + error_message = "SecureString must use the supplied value_wo_version and key_id." + } + + assert { + condition = output.secure_type + error_message = "secure_type output should be true." + } +} + +run "secure_string_value_routed_to_write_only" { + command = plan + + variables { + type = "SecureString" + key_id = "arn:aws:kms:eu-west-2:111111111111:key/mock" + value = "legacy-input" + value_wo_version = 1 + } + + assert { + condition = aws_ssm_parameter.this[0].type == "SecureString" && aws_ssm_parameter.this[0].value_wo_version == 1 + error_message = "value for a SecureString must be accepted and sent via the write-only argument." + } +} + +run "ignore_value_changes" { + command = plan + + variables { + type = "SecureString" + key_id = "arn:aws:kms:eu-west-2:111111111111:key/mock" + value_wo = "seed" + value_wo_version = 1 + ignore_value_changes = true + } + + assert { + condition = length(aws_ssm_parameter.this) == 0 && length(aws_ssm_parameter.ignore_value) == 1 + error_message = "ignore_value_changes should use the ignore_value resource." + } +} + +run "parameter_name_override" { + command = plan + + variables { + type = "String" + value = "x" + parameter_name = "/custom/path" + } + + assert { + condition = aws_ssm_parameter.this[0].name == "/custom/path" + error_message = "parameter_name should override the context-derived name." + } +} + +run "disabled" { + command = plan + + variables { + enabled = false + type = "SecureString" + } + + assert { + condition = length(aws_ssm_parameter.this) == 0 && length(aws_ssm_parameter.ignore_value) == 0 + error_message = "No resources should be created when disabled." + } +} + +run "secure_string_requires_version" { + command = plan + + variables { + type = "SecureString" + key_id = "arn:aws:kms:eu-west-2:111111111111:key/mock" + value_wo = "secret" + } + + expect_failures = [var.value_wo_version] +} + +run "secure_string_requires_key" { + command = plan + + variables { + type = "SecureString" + value_wo = "secret" + value_wo_version = 1 + } + + expect_failures = [var.key_id] +} + +run "value_wo_rejected_for_non_secure" { + command = plan + + variables { + type = "StringList" + values = ["a"] + value_wo = "secret" + } + + expect_failures = [var.value_wo] +} + +run "values_rejected_for_string" { + command = plan + + variables { + type = "String" + values = ["a"] + } + + expect_failures = [var.values] +} + +run "value_and_value_wo_mutually_exclusive" { + command = plan + + variables { + type = "SecureString" + key_id = "arn:aws:kms:eu-west-2:111111111111:key/mock" + value = "a" + value_wo = "b" + value_wo_version = 1 + } + + expect_failures = [var.value_wo] +} + +run "string_requires_value" { + command = plan + + variables { + type = "String" + } + + expect_failures = [var.value] +} diff --git a/infrastructure/modules/ssm-parameter-wo/variables.tf b/infrastructure/modules/ssm-parameter-wo/variables.tf new file mode 100644 index 00000000..b7d94ad1 --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/variables.tf @@ -0,0 +1,151 @@ +################################################################ +# Parameter definition +################################################################ + +variable "type" { + description = "Type of the parameter. Valid types are `String`, `StringList` and `SecureString`" + type = string + + validation { + condition = contains(["String", "StringList", "SecureString"], var.type) + error_message = "`type` must be either \"String\", \"StringList\", or \"SecureString\"" + } +} + +variable "parameter_name" { + description = "Optional override for the full SSM parameter name (e.g., `/bcss/prod/myapp/config`). When provided, this takes precedence over the context-derived name. If not specified, the parameter name is derived from context as `/////`." + type = string + default = null + + validation { + condition = var.parameter_name == null || can(regex("^/", coalesce(var.parameter_name, "/"))) + error_message = "parameter_name must start with a forward slash, e.g. \"/bcss/prod/myapp/config\"." + } +} + +variable "description" { + description = "Description of the parameter" + type = string + default = null +} + +variable "tier" { + description = "Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource" + type = string + default = null +} + +variable "ssm_data_type" { + description = "Data type of the parameter. Valid values: `text`, `aws:ssm:integration` and `aws:ec2:image` for AMI format, see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-ec2-aliases.html" + type = string + default = null +} + +variable "allowed_pattern" { + description = "Regular expression used to validate the parameter value" + type = string + default = null +} + +variable "overwrite" { + description = "Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform" + type = bool + default = null +} + +variable "ignore_value_changes" { + description = "Whether to create the SSM parameter and then ignore changes to its value, e.g. when the value is managed in the console or by rotation automation" + type = bool + default = false +} + +################################################################ +# Encryption +################################################################ + +variable "key_id" { + description = "KMS key ID or ARN for encrypting a `SecureString`" + type = string + default = null + + validation { + condition = !module.this.enabled || var.type != "SecureString" || var.key_id != null + error_message = "`key_id` must be specified when `type` is \"SecureString\"" + } +} + +################################################################ +# Values +# +# Cross-variable rules live in variable validations rather than +# validations.tf: they must fail before the provider's own +# argument checks on aws_ssm_parameter, which preconditions do not. +################################################################ + +variable "value" { + description = "Value of the parameter. For `String`/`StringList` it is stored in state as `insecure_value`. For `SecureString` it is sent via the write-only `value_wo` argument and never stored; prefer `value_wo` so ephemeral values can be used." + type = string + default = null + sensitive = true + + validation { + condition = !module.this.enabled || var.type == "SecureString" || var.value != null || length(var.values) > 0 + error_message = "String and StringList require value or values." + } + + validation { + condition = !(var.value != null && length(var.values) > 0) + error_message = "value and values are mutually exclusive; specify only one." + } +} + +variable "values" { + description = "List of values for a `StringList` parameter (JSON-encoded before being stored)." + type = list(string) + default = [] + sensitive = true + + validation { + condition = length(var.values) == 0 || var.type == "StringList" + error_message = "values is only valid when type is \"StringList\"." + } +} + +variable "value_wo" { + description = "Write-only value for a `SecureString` parameter. Accepts ephemeral values and is never stored in state or plan files. Requires `value_wo_version`." + type = string + default = null + sensitive = true + ephemeral = true + + validation { + condition = var.value_wo == null || var.type == "SecureString" + error_message = "value_wo is only valid when type is \"SecureString\"." + } + + validation { + condition = !(var.value != null && var.value_wo != null) + error_message = "value and value_wo are mutually exclusive; specify only one." + } + + validation { + condition = !module.this.enabled || var.type != "SecureString" || var.value != null || var.value_wo != null + error_message = "SecureString requires value_wo (preferred) or value." + } +} + +variable "value_wo_version" { + description = "Version trigger for the write-only SecureString value. Required for `SecureString`; Terraform only re-sends the value when this number changes." + type = number + default = null + + validation { + condition = !module.this.enabled || var.type != "SecureString" || var.value_wo_version != null + error_message = "`value_wo_version` must be specified when `type` is \"SecureString\"" + } + + validation { + condition = var.value_wo_version == null || var.type == "SecureString" + error_message = "value_wo_version is only valid when type is \"SecureString\"." + } +} diff --git a/infrastructure/modules/ssm-parameter-wo/versions.tf b/infrastructure/modules/ssm-parameter-wo/versions.tf new file mode 100644 index 00000000..bdaae8ef --- /dev/null +++ b/infrastructure/modules/ssm-parameter-wo/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} diff --git a/infrastructure/modules/ssm-parameter/validations.tf b/infrastructure/modules/ssm-parameter/validations.tf index c9e4667c..545b080b 100644 --- a/infrastructure/modules/ssm-parameter/validations.tf +++ b/infrastructure/modules/ssm-parameter/validations.tf @@ -20,7 +20,7 @@ resource "terraform_data" "validations" { error_message = "value and values are mutually exclusive; specify only one." } precondition { - condition = var.type != "SecureString" || var.value_wo_version == null || (var.value != null || var.value_wo_version != null) + condition = var.value_wo_version == null || var.type == "SecureString" error_message = "value_wo_version is only valid when type is \"SecureString\"." } precondition { diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 26255876..d7d48418 100644 --- a/scripts/config/generate-available-modules.yaml +++ b/scripts/config/generate-available-modules.yaml @@ -177,6 +177,10 @@ sqs: description: "SQS queue with encryption" wraps: "—" +ssm-parameter-wo: + description: "SSM parameter with write-only, ephemeral-capable SecureString values (never stored in state)" + wraps: "Native resources" + ssm-patch-manager: description: "SSM Patch Manager for automated OS patching" wraps: "cloudposse/ssm-patch-manager/aws" diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk index 8ae21211..1d844bd3 100644 --- a/scripts/terraform/terraform.mk +++ b/scripts/terraform/terraform.mk @@ -45,6 +45,15 @@ clean:: # Remove Terraform files (terraform) - optional: terraform_dir|dir=[path dir=$(or ${terraform_dir}, ${dir}) \ opts=$(or ${terraform_opts}, ${opts}) +terraform-test: # Run terraform test for modules with a tests directory - optional: module=[module name under infrastructure/modules, default is all modules with tests] @Testing + for tests_dir in infrastructure/modules/$(or ${module},*)/tests; do + [[ -d "$${tests_dir}" ]] || continue + module_dir="$${tests_dir%/tests}" + echo "==> $${module_dir}" + mise x -- terraform -chdir="$${module_dir}" init -backend=false -input=false >/dev/null + mise x -- terraform -chdir="$${module_dir}" test + done + _terraform: # Terraform command wrapper - mandatory: cmd=[command to execute]; optional: dir=[path to a directory where the command will be executed, relative to the project's top-level directory, default is one of the module variables or the example directory, if not set], opts=[options to pass to the Terraform command, default is none/empty] dir=$(or ${dir}, ${TERRAFORM_STACK}); . scripts/terraform/terraform.lib.sh; terraform-${cmd} # 'dir' and 'opts' are accessible by the function as environment variables, if set @@ -74,4 +83,5 @@ ${VERBOSE}.SILENT: \ terraform-install \ terraform-plan \ terraform-shellscript-lint \ + terraform-test \ terraform-validate \