Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 43 additions & 42 deletions infrastructure/modules/cognito/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 58 additions & 7 deletions infrastructure/modules/cognito/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
`<module.this.id>-users-pool`
* Creates a Cognito domain by default, following the prior module behaviour
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -113,9 +116,31 @@ module "cognito" {
rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]"
},
]

# 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
Expand Down Expand Up @@ -173,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.

<!-- vale off -->
<!-- markdownlint-disable -->
<!-- BEGIN_TF_DOCS -->
Expand All @@ -188,20 +237,22 @@ If those become required later, they can be added back with an explicit shared-r

| Name | Version |
| ---- | ------- |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.50.0 |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.68.0 |
| <a name="provider_terraform"></a> [terraform](#provider\_terraform) | n/a |

## Modules

| Name | Source | Version |
| ---- | ------ | ------- |
| <a name="module_cognito"></a> [cognito](#module\_cognito) | lgallard/cognito-user-pool/aws | 4.0.2 |
| <a name="module_cognito"></a> [cognito](#module\_cognito) | lgallard/cognito-user-pool/aws | 5.1.4 |
| <a name="module_this"></a> [this](#module\_this) | ../tags | n/a |

## Resources

| 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
Expand All @@ -217,7 +268,7 @@ If those become required later, they can be added back with an explicit shared-r
| <a name="input_attribute_names"></a> [attribute\_names](#input\_attribute\_names) | Compatibility list of simple string schema attributes. Used to derive string\_schemas when string\_schemas is empty. | `list(string)` | <pre>[<br/> "acr",<br/> "amr",<br/> "email",<br/> "idassurancelevel",<br/> "nhsid_nrbac_roles",<br/> "bcss_username",<br/> "sid",<br/> "uid"<br/>]</pre> | no |
| <a name="input_attributes"></a> [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,<br/>in the order they appear in the list. New attributes are appended to the<br/>end of the list. The elements of the list are joined by the `delimiter`<br/>and treated as a single ID element. | `list(string)` | `[]` | no |
| <a name="input_aws_region"></a> [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no |
| <a name="input_bootstrap_users"></a> [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. | <pre>list(object({<br/> uuid = string<br/> bcss_username = string<br/> id_assurance_level = string<br/> rbac_role = string<br/> user_password = optional(string)<br/> }))</pre> | `[]` | no |
| <a name="input_bootstrap_users"></a> [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. | <pre>list(object({<br/> uuid = string<br/> bcss_username = string<br/> id_assurance_level = string<br/> rbac_role = string<br/> user_password = optional(string)<br/> }))</pre> | `[]` | no |
| <a name="input_context"></a> [context](#input\_context) | Single object for setting entire context at once.<br/>See description of individual variables for details.<br/>Leave string and numeric variables as `null` to use default value.<br/>Individual variable settings (non-null) override settings in context object,<br/>except for attributes, tags, and additional\_tag\_map, which are merged. | `any` | <pre>{<br/> "additional_tag_map": {},<br/> "attributes": [],<br/> "delimiter": null,<br/> "descriptor_formats": {},<br/> "enabled": true,<br/> "environment": null,<br/> "id_length_limit": null,<br/> "label_key_case": null,<br/> "label_order": [],<br/> "label_value_case": null,<br/> "labels_as_tags": [<br/> "unset"<br/> ],<br/> "name": null,<br/> "project": null,<br/> "regex_replace_chars": null,<br/> "region": null,<br/> "service": null,<br/> "stack": null,<br/> "tags": {},<br/> "terraform_source": null,<br/> "workspace": null<br/>}</pre> | no |
| <a name="input_create"></a> [create](#input\_create) | Determines whether Cognito resources will be created. | `bool` | `true` | no |
| <a name="input_data_classification"></a> [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no |
Expand Down Expand Up @@ -252,7 +303,7 @@ If those become required later, they can be added back with an explicit shared-r
| <a name="input_terraform_source"></a> [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 |
| <a name="input_tool"></a> [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no |
| <a name="input_user_email"></a> [user\_email](#input\_user\_email) | Email attribute applied to bootstrap Cognito users. | `string` | `"nhsdigital.axe@nhs.net"` | no |
| <a name="input_user_password"></a> [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 |
| <a name="input_user_password"></a> [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 |
| <a name="input_user_pool_name"></a> [user\_pool\_name](#input\_user\_pool\_name) | Override for the Cognito user pool name. Defaults to `<module.this.id>-users-pool`. | `string` | `null` | no |
| <a name="input_user_pool_tier"></a> [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 |
| <a name="input_workspace"></a> [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no |
Expand Down
21 changes: 21 additions & 0 deletions infrastructure/modules/cognito/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions infrastructure/modules/cognito/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@
# * 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" {
source = "lgallard/cognito-user-pool/aws"
version = "4.0.2"
version = "5.1.4"

enabled = module.this.enabled

Expand Down Expand Up @@ -47,7 +50,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 = local.bootstrap_user_passwords[each.key]
message_action = var.message_action

attributes = {
Expand All @@ -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]
}
Loading
Loading