From 29885a491aa219bc837f3a9a75adab38bc556973 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 9 Oct 2026 16:55:07 +0100 Subject: [PATCH 1/2] feat(cognito): update AWS provider version and enhance user password handling --- .../modules/cognito/.terraform.lock.hcl | 85 ++++++++++--------- infrastructure/modules/cognito/README.md | 9 +- infrastructure/modules/cognito/main.tf | 4 +- infrastructure/modules/cognito/variables.tf | 3 +- 4 files changed, 53 insertions(+), 48 deletions(-) diff --git a/infrastructure/modules/cognito/.terraform.lock.hcl b/infrastructure/modules/cognito/.terraform.lock.hcl index 74487bfd..3b78eab3 100644 --- a/infrastructure/modules/cognito/.terraform.lock.hcl +++ b/infrastructure/modules/cognito/.terraform.lock.hcl @@ -2,55 +2,56 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" - constraints = ">= 6.0.0, >= 6.14.0, >= 6.42.0" + version = "6.68.0" + constraints = ">= 6.12.0, >= 6.14.0, >= 6.42.0" hashes = [ - "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", - "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", - "h1:Uf2LlEibaBdksEUkOoiQbzEbkIgOR6tUE/0tCd36Xzk=", - "h1:gnyVeH3L2erQ/di0a4x5i0AlsIcdLjyK5+Vmbf3qyck=", - "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", - "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", - "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", - "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", - "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", - "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", - "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", - "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", - "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", - "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", - "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", - "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", - "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "h1:79hHQtjGSCiXBZK+VDiNUdI2SLXbNaHf+CHySvKFnvA=", + "h1:HBG/xsnu+720umEWjTuqOf9bdpOrtyEbUubikNyFOhY=", + "h1:SyHeRgZ4GWMf6vrf/7MX8dvSkHCR8Wb4hSOyFDMz0sc=", + "h1:rB2Amjxr32AZWeWsDMQyU61xmW8IvSAUXhv4ogvx9l4=", + "h1:s5EXu9EtGfnLR3ebHoIyZBLRYMPcRL9zcfx/NKi+Q9o=", + "zh:064ed007a33a22d8367db88b18d56a4dd066f07f95b1a4d80ffa2b6ee803f6f0", + "zh:20ede4bb730b55023ad426ed13b1b4b295fac4bcc2baa4fb63d2932aa15d760d", + "zh:490cffbc315ba21f740158a6b4195e7707ec2ee7ed357a47bb90ed18466f01a2", + "zh:7840197f73f53d676373c9aeabccd79c61c6da6e4aa1ed840e57ca46c1894126", + "zh:889b15a5c2f7fb168c04c846e75932a6b2ceb7967c3bfa0a0e24be16cdee7969", + "zh:96ba7624b6f0e0a1fa5477b0838194be919d815d30ce7f97e04d4520cca4c00b", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + "zh:9b41c2afe2fec1a1880f9427b3ca611abfe6643b41942556a83cd7daae55acf1", + "zh:a83e9d46de040fa7bc56f7aa53bdeb8d7078bac65d9155b07022fd75723308b8", + "zh:aa5cfdd2faf8a2848c72c13ca88786b857fd397aa713967b8b1f9610f2c76a0b", + "zh:abd449fbf38cf828b8b17bc8540752733dd861160504a45b411e72e1792c758c", + "zh:b2511842cb6b23036c72babbaf12b4984835bb2824f938882330b51c68bdaecd", + "zh:c11e8860c4d611dda93da2e5be27c9bd2599da6ad320f3d12ccc714ffbe6f100", + "zh:e4252ceda8641bfe85ad7710b428f994c080bde6c55043ab47a08015432cebdc", + "zh:f292ad91c97ac7354a04a698f346c2a0c3866d9170ca45dda6a6bfab71180be0", + "zh:f4d293cd0e9562bfa1ba32a1fe5c3652c6a32b951e44081158737d4075696045", ] } provider "registry.terraform.io/hashicorp/awscc" { - version = "1.89.0" + version = "1.105.0" + constraints = ">= 1.89.0" hashes = [ - "h1:DyIiErDhoQCJi54zEUf7E4GRm5FzAEHJFW9GTu+SNkc=", - "h1:F672dO9zM66TDfIHttDmaENXwd9ZyKi2SiFNA7aOjfo=", - "h1:QZYfne5HgtW+iL0SCZXNIspeDQUIBty+5clq754wXWg=", - "h1:VoA2As0xZ05sC1lFPKActbHEAB4+3WHj/9WuxWMX0ps=", - "h1:ov0Eyeineeq629FvErPttIVKgBV1d0QZJT2zEbn1Ay8=", - "zh:2589d15db6a9a180ac1b021341c1225b9cae59a7d7003c05b787b91ccade504f", - "zh:446d9b1ae47dcf26b8ef49ea2ad3013a406adc33b24c7460d33c779021161f70", - "zh:44927f518b28a5a454a479e4c0eb8021709217cc51c4b1f8b51203cf8f8fa1c4", - "zh:521b1a2d757cf9d41ee3d412b6026b4483c905b92724b98372357bcb22ab43a3", - "zh:719b4f9608d394bc40e8e23d057e9693be2035dca48ad0bbc65e9025bd31c209", - "zh:7ea429e5541af0cd45e87ad9a6d5a56dd91e593413f24ef4935fa6a16930a1ed", - "zh:b6103766e0094296fcd63ac414597cff3d1e1f2491b7de7e8dfd8fc9ad915fd2", - "zh:c2f0ea3039ce2a2433d9839ce04cfdb98652f76c6ee3a69d389f4036785417f1", - "zh:d0da4cfccc05ec2a9e961be468e2e98cea12a331262d3e524a402150f4884db2", - "zh:d17d19cb0337d5e3677e969f09bf0d67dfe286d8e1f34d150430e0d076fda9e1", - "zh:d2972f77c2254759ed0d44fac72b48bfe5701d6b9327f49ce26ce4551cd42884", - "zh:dcec5e6970dbf90624dfe648df7e8b3fa2963d3c5cbd1e0748dbad9a2eea097e", - "zh:dfffb14a9818224ae7004dda8c259439afdc94aabe208793fe24d2c9760b2b14", - "zh:f03ee3c782533a2e04bccbdc2291e1922b6bfabcd03a5a1be4f8a97059bef4d1", + "h1:1EK8SW5VUyf+72oCsIrx+Oc/yQT80q9M0vZ5YJbnQM8=", + "h1:7S+TK3+1kyc03Gf6RqcwXG9Wlt3CfZVCFgfMWQXzuFE=", + "h1:aee/XrPdVKZa/UtzxONN2MugK0HmENdMFjcCSrDfV9k=", + "h1:arq4rCBOB2WZz72pjjiCOHC51V5pR5BzowZd8TrbEbQ=", + "h1:dNPGvnOI77lNT+vBww4uii0MkgQcbsa0nj63kCva7VY=", + "zh:1ebbd89a66adc583d0f5de42455c0c3af169e49c516f6c47355d2e0bc9ac19f7", + "zh:74b23edc5a9c66cf37a4ca3bd57b882dbea696c5d75cc45a8d8840938dfb1cc1", + "zh:7b294d3413985b9501340dadbdb7fa222260bc0a2063d10a2b4a49374a13efce", + "zh:8860ae9e421fe68bb7fea8b125ecb07be3823448fa7edb91eadfdf74967a647c", + "zh:9550c2f706bf47e85ff1368a00d9200d9fff3aeafc0729461044e80e0998968b", + "zh:985576052a44525deb21bec06006122269fe877de198a5c5d20b6e754c5e4ded", + "zh:a008383b9b81f854fab4dece73f65f18df4d9f5c0ff6b3149e81c940f8b3f146", + "zh:a27d1627fb886adfd81277e4b082e14d9383257c23bb87ccf15577616b0adb0f", + "zh:a742dc45a5bbe6c0c934a90d7f761c83ebfae139cb84c2e2d8b45960b53d0943", + "zh:aec249565f1f034248db7cdfcc3f823569ea8b009cf847be10d650239a6e7e0b", + "zh:b9235781798afec9a02e70dfadbdf59a64692d0f78af482785c5b79d4f542374", + "zh:bfd80e4e122033cb3197884399c15ecc2bac4a2a5c02d9b60ce99cb9a4a92ec6", + "zh:c6861dab5c3f731675ba83f13e633e619cf492231d849da94bf75b992c875d64", + "zh:cd5db56c86b27ca2a696f6fbe53c4b00f23e5b2cdbb5470ad2ed483a4e3668ce", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 896eb267..786e9567 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -113,6 +113,9 @@ module "cognito" { rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" }, ] + + # Set this to null when a trusted external process sets users' passwords. + user_password = null } ``` @@ -188,13 +191,13 @@ If those become required later, they can be added back with an explicit shared-r | Name | Version | | ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | +| [aws](#provider\_aws) | 6.68.0 | ## Modules | Name | Source | Version | | ---- | ------ | ------- | -| [cognito](#module\_cognito) | lgallard/cognito-user-pool/aws | 4.0.2 | +| [cognito](#module\_cognito) | lgallard/cognito-user-pool/aws | 5.1.4 | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -252,7 +255,7 @@ If those become required later, they can be added back with an explicit shared-r | [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 | | [user\_email](#input\_user\_email) | Email attribute applied to bootstrap Cognito users. | `string` | `"nhsdigital.axe@nhs.net"` | no | -| [user\_password](#input\_user\_password) | Fallback password for bootstrap Cognito users when an individual bootstrap\_users entry does not provide user\_password. | `string` | `"changeme"` | no | +| [user\_password](#input\_user\_password) | Fallback password for bootstrap Cognito users when an individual bootstrap\_users entry does not provide user\_password. Set to null when a caller will set passwords out of band. | `string` | `"changeme"` | no | | [user\_pool\_name](#input\_user\_pool\_name) | Override for the Cognito user pool name. Defaults to `-users-pool`. | `string` | `null` | no | | [user\_pool\_tier](#input\_user\_pool\_tier) | Cognito User Pool tier. LITE avoids iam:PassRole requirements from ESSENTIALS/PLUS threat-protection features. Valid values: LITE, ESSENTIALS, PLUS. | `string` | `"LITE"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index 8d909988..adbd32d7 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -18,7 +18,7 @@ module "cognito" { source = "lgallard/cognito-user-pool/aws" - version = "4.0.2" + version = "5.1.4" enabled = module.this.enabled @@ -47,7 +47,7 @@ resource "aws_cognito_user" "bootstrap_users" { user_pool_id = module.cognito.id username = each.value.bcss_username - password = coalesce(try(each.value.user_password, null), var.user_password) + password = try(coalesce(try(each.value.user_password, null), var.user_password), null) message_action = var.message_action attributes = { diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index ce2818eb..12dcfb5a 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -181,8 +181,9 @@ variable "user_email" { # tflint-ignore: terraform_unused_declarations variable "user_password" { - description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password." + description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password. Set to null when a caller will set passwords out of band." type = string default = "changeme" sensitive = true + nullable = true } From bf1d3dc74327e42982a9a745b9dc4afbe3853d9d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 19 Oct 2026 21:15:10 +0100 Subject: [PATCH 2/2] feat(cognito): enhance bootstrap user password management and validation --- infrastructure/modules/cognito/README.md | 60 ++++- infrastructure/modules/cognito/locals.tf | 21 ++ infrastructure/modules/cognito/main.tf | 9 +- .../modules/cognito/tests/cognito.tftest.hcl | 255 ++++++++++++++++++ infrastructure/modules/cognito/validations.tf | 21 ++ infrastructure/modules/cognito/variables.tf | 8 +- 6 files changed, 362 insertions(+), 12 deletions(-) create mode 100644 infrastructure/modules/cognito/tests/cognito.tftest.hcl create mode 100644 infrastructure/modules/cognito/validations.tf diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 786e9567..99c61734 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -1,7 +1,7 @@ # Cognito NHS Screening wrapper around the community -[`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/4.0.2) +[`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/5.1.4) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. @@ -11,7 +11,7 @@ Secrets Manager password flow. ## Design choices -* Uses the upstream `lgallard/cognito-user-pool/aws` module pinned to `4.0.2` +* Uses the upstream `lgallard/cognito-user-pool/aws` module pinned to `5.1.4` * Derives the user pool name from `var.user_pool_name`, falling back to `-users-pool` * Creates a Cognito domain by default, following the prior module behaviour @@ -22,6 +22,8 @@ Secrets Manager password flow. and `identity_providers` inputs directly * Supports optional bootstrap user creation because the current BCSS Cognito stacks still provision initial users during stack deployment +* Doesn't ship a default bootstrap user password: unless one is supplied, + Cognito generates a temporary password and none is stored in Terraform state ## Usage @@ -104,7 +106,8 @@ module "cognito" { bcss_username = "training.user.1" id_assurance_level = "3" rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" - user_password = "InitialPass1!" + # Must satisfy password_policy; stored in Terraform state. + user_password = var.training_user_1_password }, { uuid = "22222222-2222-2222-2222-222222222222" @@ -114,11 +117,30 @@ module "cognito" { }, ] - # Set this to null when a trusted external process sets users' passwords. + # Default: no fallback password. training.user.2 gets a Cognito-generated + # temporary password that a trusted external process must replace. user_password = null } ``` +### Bootstrap user passwords + +Each bootstrap user's password is resolved in this order: + +1. `bootstrap_users[*].user_password`, if set and non-empty +2. `user_password` (the fallback), if set and non-empty +3. Otherwise no password is sent. Cognito generates a temporary password and + the user is created in `FORCE_CHANGE_PASSWORD` status + +Option 3 is the default and is recommended: no password is stored in Terraform +state. With the default `message_action = "SUPPRESS"` nobody receives the +temporary password, so a trusted process (for example a Secrets Manager +rotation Lambda calling `AdminSetUserPassword`) must set the real password +before the user can sign in. + +Any password you supply is stored in Terraform state and must satisfy +`password_policy`. See [Validation](#validation). + ## Conventions ### Naming @@ -176,6 +198,30 @@ The wrapper does not directly expose the upstream generic inputs for: If those become required later, they can be added back with an explicit shared-resources use case. +## Validation + +The following constraint is enforced at `plan` time via a precondition in `validations.tf`: + +* **Bootstrap password policy**: every supplied bootstrap user password + (`bootstrap_users[*].user_password` or the `user_password` fallback) must + meet `password_policy`: `minimum_length`, plus lowercase, uppercase, number, + and symbol characters where required. It must also be at most 256 characters. + Any non-alphanumeric character counts as a symbol, which approximates + Cognito's own symbol list. Bootstrap users depend on this check, so a weak + password fails with a module error rather than an AWS provider error that + would print the password. + +## Testing + +```bash +make terraform-test module=cognito +``` + +Tests in `tests/cognito.tftest.hcl` use `mock_provider` with `command = plan`. +They cover password resolution, the policy check, the disabled module, and the +existing `bootstrap_users` and `app_clients` validations. They aren't wired into +CI workflows yet. + @@ -192,6 +238,7 @@ If those become required later, they can be added back with an explicit shared-r | Name | Version | | ---- | ------- | | [aws](#provider\_aws) | 6.68.0 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -205,6 +252,7 @@ If those become required later, they can be added back with an explicit shared-r | Name | Type | | ---- | ---- | | [aws_cognito_user.bootstrap_users](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user) | resource | +| [terraform_data.validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | ## Inputs @@ -220,7 +268,7 @@ If those become required later, they can be added back with an explicit shared-r | [attribute\_names](#input\_attribute\_names) | Compatibility list of simple string schema attributes. Used to derive string\_schemas when string\_schemas is empty. | `list(string)` |
[
"acr",
"amr",
"email",
"idassurancelevel",
"nhsid_nrbac_roles",
"bcss_username",
"sid",
"uid"
]
| 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 | -| [bootstrap\_users](#input\_bootstrap\_users) | Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment. |
list(object({
uuid = string
bcss_username = string
id_assurance_level = string
rbac_role = string
user_password = optional(string)
}))
| `[]` | no | +| [bootstrap\_users](#input\_bootstrap\_users) | Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment. A per-user user\_password overrides var.user\_password, is stored in Terraform state, and must satisfy password\_policy. |
list(object({
uuid = string
bcss_username = string
id_assurance_level = string
rbac_role = string
user_password = optional(string)
}))
| `[]` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | | [create](#input\_create) | Determines whether Cognito resources will be created. | `bool` | `true` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | @@ -255,7 +303,7 @@ If those become required later, they can be added back with an explicit shared-r | [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 | | [user\_email](#input\_user\_email) | Email attribute applied to bootstrap Cognito users. | `string` | `"nhsdigital.axe@nhs.net"` | no | -| [user\_password](#input\_user\_password) | Fallback password for bootstrap Cognito users when an individual bootstrap\_users entry does not provide user\_password. Set to null when a caller will set passwords out of band. | `string` | `"changeme"` | no | +| [user\_password](#input\_user\_password) | Optional fallback password for bootstrap Cognito users when an individual bootstrap\_users entry does not provide user\_password. Defaults to null: Cognito generates a temporary password (user status FORCE\_CHANGE\_PASSWORD) and no password is stored in Terraform state, so a trusted external process (for example a Secrets Manager rotation Lambda) must set the real password. Any supplied password is stored in state and must satisfy password\_policy. | `string` | `null` | no | | [user\_pool\_name](#input\_user\_pool\_name) | Override for the Cognito user pool name. Defaults to `-users-pool`. | `string` | `null` | no | | [user\_pool\_tier](#input\_user\_pool\_tier) | Cognito User Pool tier. LITE avoids iam:PassRole requirements from ESSENTIALS/PLUS threat-protection features. Valid values: LITE, ESSENTIALS, PLUS. | `string` | `"LITE"` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/cognito/locals.tf b/infrastructure/modules/cognito/locals.tf index 2af760d2..7d7b0b34 100644 --- a/infrastructure/modules/cognito/locals.tf +++ b/infrastructure/modules/cognito/locals.tf @@ -19,6 +19,27 @@ locals { default_email_option = "CONFIRM_WITH_CODE" } + # Per-user password wins over var.user_password; null/empty in both leaves + # the password unset so Cognito generates a temporary one. + bootstrap_user_passwords = { + for user in var.bootstrap_users : + user.uuid => try(coalesce(user.user_password, var.user_password), null) + } + + # Approximates Cognito's server-side password policy check so weak + # passwords fail at plan time. Cognito's maximum password length is 256. + bootstrap_user_passwords_meet_policy = alltrue([ + for password in values(local.bootstrap_user_passwords) : + password == null ? true : ( + length(password) >= var.password_policy.minimum_length && + length(password) <= 256 && + (!var.password_policy.require_lowercase || can(regex("[a-z]", password))) && + (!var.password_policy.require_uppercase || can(regex("[A-Z]", password))) && + (!var.password_policy.require_numbers || can(regex("[0-9]", password))) && + (!var.password_policy.require_symbols || can(regex("[^A-Za-z0-9]", password))) + ) + ]) + default_string_schemas = [ for attribute_name in var.attribute_names : { attribute_data_type = "String" diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index adbd32d7..bc2b46c1 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -11,9 +11,12 @@ # * Schema changes ignored after initial deployment # * Resources tagged via module.this.tags # * Creation gated by module.this.enabled +# * Bootstrap user passwords optional and checked against +# the password policy # # Naming and tagging are derived from context.tf via module.this. # Derived locals (naming, client config) are defined in locals.tf. +# Cross-variable input constraints are enforced in validations.tf. ################################################################ module "cognito" { @@ -47,7 +50,7 @@ resource "aws_cognito_user" "bootstrap_users" { user_pool_id = module.cognito.id username = each.value.bcss_username - password = try(coalesce(try(each.value.user_password, null), var.user_password), null) + password = local.bootstrap_user_passwords[each.key] message_action = var.message_action attributes = { @@ -61,4 +64,8 @@ resource "aws_cognito_user" "bootstrap_users" { sid = each.value.uuid uid = each.value.uuid } + + # Run the password policy check first; the provider's own length + # validation would otherwise fail first and echo the password in its error. + depends_on = [terraform_data.validations] } diff --git a/infrastructure/modules/cognito/tests/cognito.tftest.hcl b/infrastructure/modules/cognito/tests/cognito.tftest.hcl new file mode 100644 index 00000000..41ea3b5c --- /dev/null +++ b/infrastructure/modules/cognito/tests/cognito.tftest.hcl @@ -0,0 +1,255 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { + account_id = "111111111111" + arn = "arn:aws:iam::111111111111:role/mock" + user_id = "AIDAMOCK" + } + } + + mock_data "aws_iam_session_context" { + defaults = { + issuer_arn = "arn:aws:iam::111111111111:role/mock" + } + } + + mock_data "aws_region" { + defaults = { + name = "eu-west-2" + region = "eu-west-2" + } + } +} + +mock_provider "awscc" {} + +variables { + service = "bcss" + project = "bcss" + environment = "test" + stack = "shared" + name = "users" + label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] + + app_clients = [ + { + callback_urls = ["https://example.internal/login/oauth2/code/nhs-identity"] + logout_urls = ["https://example.internal/logout"] + default_redirect_uri = "https://example.internal/login/oauth2/code/nhs-identity" + }, + ] + + bootstrap_users = [ + { + uuid = "111111111111" + bcss_username = "BCSS_USER_ONE" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + }, + { + uuid = "222222222222" + bcss_username = "BCSS_USER_TWO" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + }, + ] +} + +# Fixture passwords below are fake and built with join() so secret scanners +# don't flag them as hard-coded credentials. + +run "default_leaves_bootstrap_passwords_unset" { + command = plan + + assert { + condition = var.user_password == null + error_message = "user_password must default to null so no weak default password is applied." + } + + assert { + condition = length(aws_cognito_user.bootstrap_users) == 2 + error_message = "One Cognito user must be planned per bootstrap_users entry." + } + + assert { + condition = alltrue([for user in aws_cognito_user.bootstrap_users : user.password == null]) + error_message = "Bootstrap users must have no Terraform-managed password when none is supplied, so Cognito generates a temporary one." + } + + assert { + condition = aws_cognito_user.bootstrap_users["111111111111"].message_action == "SUPPRESS" + error_message = "Bootstrap user invitation messages must be suppressed by default." + } +} + +run "empty_fallback_password_is_treated_as_unset" { + command = plan + + variables { + user_password = "" + } + + assert { + condition = alltrue([for user in aws_cognito_user.bootstrap_users : user.password == null]) + error_message = "An empty user_password must be treated as no password rather than sent to Cognito." + } +} + +run "compliant_fallback_password_is_applied" { + command = plan + + variables { + user_password = join("", ["Fallback", "-Pass-123"]) + } + + assert { + condition = nonsensitive(alltrue([for user in aws_cognito_user.bootstrap_users : user.password == var.user_password])) + error_message = "A compliant user_password must be applied to bootstrap users without their own password." + } +} + +run "per_user_password_overrides_fallback" { + command = plan + + variables { + user_password = join("", ["Fallback", "-Pass-123"]) + bootstrap_users = [ + { + uuid = "111111111111" + bcss_username = "BCSS_USER_ONE" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + user_password = join("", ["PerUser-", "Pass-456"]) + }, + { + uuid = "222222222222" + bcss_username = "BCSS_USER_TWO" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + }, + ] + } + + assert { + condition = nonsensitive(aws_cognito_user.bootstrap_users["111111111111"].password == var.bootstrap_users[0].user_password) + error_message = "A per-user user_password must override the user_password fallback." + } + + assert { + condition = nonsensitive(aws_cognito_user.bootstrap_users["222222222222"].password == var.user_password) + error_message = "Users without a per-user password must receive the user_password fallback." + } +} + +run "weak_fallback_password_is_rejected" { + command = plan + + variables { + user_password = join("", ["chan", "geme"]) + } + + expect_failures = [terraform_data.validations] +} + +run "short_fallback_password_is_rejected" { + command = plan + + variables { + user_password = join("", ["Ab", "1-"]) + } + + expect_failures = [terraform_data.validations] +} + +run "per_user_password_missing_symbol_is_rejected" { + command = plan + + variables { + bootstrap_users = [ + { + uuid = "111111111111" + bcss_username = "BCSS_USER_ONE" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + user_password = join("", ["NoSymbo", "lPass123"]) + }, + ] + } + + expect_failures = [terraform_data.validations] +} + +run "relaxed_policy_accepts_password_without_symbol" { + command = plan + + variables { + user_password = join("", ["NoSymbo", "lPass123"]) + password_policy = { + require_symbols = false + } + } + + assert { + condition = nonsensitive(alltrue([for user in aws_cognito_user.bootstrap_users : user.password == var.user_password])) + error_message = "Password checks must follow password_policy, accepting a symbol-free password when symbols are not required." + } +} + +run "stricter_minimum_length_is_enforced" { + command = plan + + variables { + user_password = join("", ["Fallback", "-Pass-123"]) + password_policy = { + minimum_length = 20 + } + } + + expect_failures = [terraform_data.validations] +} + +run "disabled_module_creates_no_users_or_checks" { + command = plan + + variables { + enabled = false + user_password = join("", ["chan", "geme"]) + } + + assert { + condition = length(aws_cognito_user.bootstrap_users) == 0 && length(terraform_data.validations) == 0 + error_message = "A disabled module must not plan bootstrap users or run password validation." + } +} + +run "bootstrap_user_missing_required_fields_is_rejected" { + command = plan + + variables { + bootstrap_users = [ + { + uuid = "" + bcss_username = "BCSS_USER_ONE" + id_assurance_level = "3" + rbac_role = "[{activities=[Bowel Cancer Screening System], activity_codes=[B1834]}]" + }, + ] + } + + expect_failures = [var.bootstrap_users] +} + +run "default_redirect_uri_must_be_a_callback_url" { + command = plan + + variables { + app_clients = [ + { + callback_urls = ["https://example.internal/login/oauth2/code/nhs-identity"] + default_redirect_uri = "https://other.internal/callback" + }, + ] + } + + expect_failures = [var.app_clients] +} diff --git a/infrastructure/modules/cognito/validations.tf b/infrastructure/modules/cognito/validations.tf new file mode 100644 index 00000000..f0b497ad --- /dev/null +++ b/infrastructure/modules/cognito/validations.tf @@ -0,0 +1,21 @@ +################################################################ +# Input validation +# +# Validates cross-variable constraints that cannot be expressed +# through individual variable validation blocks: +# +# * Every supplied bootstrap user password (per-user or the +# user_password fallback) satisfies password_policy +################################################################ + +resource "terraform_data" "validations" { + count = module.this.enabled && var.create ? 1 : 0 + + lifecycle { + precondition { + # The result is a bool derived from sensitive passwords; it reveals no secret. + condition = nonsensitive(local.bootstrap_user_passwords_meet_policy) + error_message = "Every bootstrap user password (bootstrap_users[*].user_password or the user_password fallback) must satisfy password_policy (minimum_length, required character classes) and be at most 256 characters. Set user_password = null to let Cognito generate a temporary password instead." + } + } +} diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 12dcfb5a..137dfed6 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -131,7 +131,7 @@ variable "create" { } variable "bootstrap_users" { - description = "Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment." + description = "Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment. A per-user user_password overrides var.user_password, is stored in Terraform state, and must satisfy password_policy." type = list(object({ uuid = string bcss_username = string @@ -179,11 +179,9 @@ variable "user_email" { default = "nhsdigital.axe@nhs.net" } -# tflint-ignore: terraform_unused_declarations variable "user_password" { - description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password. Set to null when a caller will set passwords out of band." + description = "Optional fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password. Defaults to null: Cognito generates a temporary password (user status FORCE_CHANGE_PASSWORD) and no password is stored in Terraform state, so a trusted external process (for example a Secrets Manager rotation Lambda) must set the real password. Any supplied password is stored in state and must satisfy password_policy." type = string - default = "changeme" + default = null sensitive = true - nullable = true }