diff --git a/infrastructure/modules/acm/main.tf b/infrastructure/modules/acm/main.tf index a4d50b8c..4b449322 100644 --- a/infrastructure/modules/acm/main.tf +++ b/infrastructure/modules/acm/main.tf @@ -43,6 +43,6 @@ module "acm" { dns_ttl = var.dns_ttl # ---------------------------------------------------------------- - region = module.this.region + region = var.aws_region tags = module.this.tags } diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 927dd6ef..c1896fab 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -12,13 +12,11 @@ Secrets Manager password flow. ## Design choices * Uses the upstream `lgallard/cognito-user-pool/aws` module pinned to `4.0.2` -* Derives the user pool name from `user_pool_name`, then `name_prefix`, then the - shared context-derived module ID +* 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 * Enables `ignore_schema_changes = true` by default because this is recommended for new Cognito deployments with custom schemas -* Keeps a small compatibility layer for legacy inputs such as `name_prefix` and - `attribute_names` * Narrows application client configuration to an `app_clients` interface instead of exposing the upstream generic `clients`, `resource_servers`, `user_groups`, and `identity_providers` inputs directly @@ -27,9 +25,11 @@ Secrets Manager password flow. ## Usage +### Minimal + ```hcl module "cognito" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=vX.Y.Z" name = "cognito" project = "shared" @@ -40,32 +40,113 @@ module "cognito" { 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" + generate_secret = true + }, + ] +} +``` + +### Common production-style + +```hcl +module "cognito" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=vX.Y.Z" + + name = "bcss" + project = "screening" + environment = "prod" + context = module.this.context + + deletion_protection = "ACTIVE" + mfa_configuration = "OPTIONAL" + + app_clients = [ + { + name = "bcss-web-client" + callback_urls = ["https://bcss.nhs.net/login/oauth2/code/nhs-identity"] + logout_urls = ["https://bcss.nhs.net/logout"] + default_redirect_uri = "https://bcss.nhs.net/login/oauth2/code/nhs-identity" + generate_secret = true + id_token_validity = 60 + refresh_token_validity = 8 + }, + ] +} +``` + +### Advanced — custom naming with bootstrap users + +```hcl +module "cognito" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=vX.Y.Z" + + name = "bcss" + project = "screening" + environment = "training" + context = module.this.context - allowed_oauth_flows_user_pool_client = true - allowed_oauth_flows = ["code"] - allowed_oauth_scopes = [ - "email", - "openid", - "profile", - "aws.cognito.signin.user.admin", - ] - supported_identity_providers = ["COGNITO"] - generate_secret = true - } + user_pool_name = "training-users-pool" + domain = "bcss-training" + app_client_name = "training-web-client" + + app_clients = [ + { + callback_urls = ["https://training.bcss.nhs.net/login/oauth2/code/nhs-identity"] + logout_urls = ["https://training.bcss.nhs.net/logout"] + default_redirect_uri = "https://training.bcss.nhs.net/login/oauth2/code/nhs-identity" + generate_secret = true + }, ] bootstrap_users = [ { uuid = "11111111-1111-1111-1111-111111111111" - bcss_username = "test.user" + bcss_username = "training.user.1" + id_assurance_level = "3" + rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" + user_password = "InitialPass1!" + }, + { + uuid = "22222222-2222-2222-2222-222222222222" + bcss_username = "training.user.2" id_assurance_level = "3" rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" - } + }, ] } ``` -## Current defaults inherited from the old module +## Conventions + +### Naming + +All names derive from `module.this.id` (provided via `context.tf`) but can be +overridden individually: + +|Resource|Default|Override variable| +|---|---|---| +|User pool name|`-users-pool`|`var.user_pool_name`| +|Domain prefix|``|`var.domain`| +|App client name|`-users-client`|`var.app_client_name`| + +### Application clients + +All clients receive the following hardcoded settings regardless of caller input: + +- `allowed_oauth_flows = ["code"]` +- `allowed_oauth_flows_user_pool_client = true` +- `allowed_oauth_scopes = ["email", "openid", "profile", "aws.cognito.signin.user.admin"]` +- `explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]` +- `supported_identity_providers = ["COGNITO"]` +- `access_token_validity = 60` (minutes) + +### Schema changes + +`ignore_schema_changes = true` is hardcoded. Cognito does not support removing +custom attributes after creation; enabling drift-detection would cause unnecessary +plan noise on every run. + +## Enforced defaults * `auto_verified_attributes = ["email"]` * `mfa_configuration = "OFF"` diff --git a/infrastructure/modules/cognito/data.tf b/infrastructure/modules/cognito/data.tf new file mode 100644 index 00000000..2502393b --- /dev/null +++ b/infrastructure/modules/cognito/data.tf @@ -0,0 +1 @@ +data "aws_region" "current" {} diff --git a/infrastructure/modules/cognito/locals.tf b/infrastructure/modules/cognito/locals.tf new file mode 100644 index 00000000..a694f168 --- /dev/null +++ b/infrastructure/modules/cognito/locals.tf @@ -0,0 +1,59 @@ +locals { + user_pool_name = coalesce(var.user_pool_name, "${module.this.id}-users-pool") + domain_name = coalesce(var.domain, module.this.id) + default_app_client_name = coalesce(var.app_client_name, "${module.this.id}-users-client") + + default_admin_create_user_config = { + allow_admin_create_user_only = false + } + + default_email_configuration = { + email_sending_account = "COGNITO_DEFAULT" + } + + default_username_configuration = { + case_sensitive = false + } + + default_verification_message_template = { + default_email_option = "CONFIRM_WITH_CODE" + } + + default_string_schemas = [ + for attribute_name in var.attribute_names : { + attribute_data_type = "String" + developer_only_attribute = false + mutable = true + name = attribute_name + required = false + string_attribute_constraints = { + min_length = "1" + max_length = "256" + } + } + ] + + cognito_clients = [ + for client in var.app_clients : { + name = try(client.name, local.default_app_client_name) + callback_urls = client.callback_urls + logout_urls = client.logout_urls + default_redirect_uri = try(client.default_redirect_uri, null) + generate_secret = try(client.generate_secret, true) + auth_session_validity = try(client.auth_session_validity, 3) + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile", "aws.cognito.signin.user.admin"] + explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"] + supported_identity_providers = ["COGNITO"] + enable_propagate_additional_user_context_data = try(client.enable_propagate_additional_user_context_data, false) + access_token_validity = 1 # 1 hour (Cognito default unit: hours) + id_token_validity = client.id_token_validity + refresh_token_validity = client.refresh_token_validity + # token_validity_units omitted — lgallard skips the block when absent; + # Cognito default units apply: hours for access/id tokens, days for refresh + prevent_user_existence_errors = try(client.prevent_user_existence_errors, null) + enable_token_revocation = try(client.enable_token_revocation, true) + } + ] +} diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index 0e8908cc..8d909988 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -1,82 +1,20 @@ -locals { - user_pool_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-pool" : null, module.this.id) - domain_name = coalesce(var.domain, var.name_prefix, local.user_pool_name) - default_app_client_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-client" : null, "${local.user_pool_name}-client") - - default_admin_create_user_config = { - allow_admin_create_user_only = false - } - - default_email_configuration = { - email_sending_account = "COGNITO_DEFAULT" - } - - default_password_policy = { - minimum_length = 8 - require_lowercase = true - require_numbers = true - require_symbols = true - require_uppercase = true - temporary_password_validity_days = 7 - password_history_size = 0 # https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-cognito-userpool-passwordpolicy.html#cfn-cognito-userpool-passwordpolicy-passwordhistorysize - } - - default_recovery_mechanisms = [ - { - name = "verified_email" - priority = 1 - }, - { - name = "verified_phone_number" - priority = 2 - } - ] - - default_username_configuration = { - case_sensitive = false - } - - default_verification_message_template = { - default_email_option = "CONFIRM_WITH_CODE" - } - - default_string_schemas = [ - for attribute_name in var.attribute_names : { - attribute_data_type = "String" - developer_only_attribute = false - mutable = true - name = attribute_name - required = false - string_attribute_constraints = { - min_length = "1" - max_length = "256" - } - } - ] - - cognito_clients = [ - for client in var.app_clients : { - name = try(client.name, local.default_app_client_name) - callback_urls = client.callback_urls - logout_urls = client.logout_urls - default_redirect_uri = try(client.default_redirect_uri, null) - generate_secret = try(client.generate_secret, true) - auth_session_validity = try(client.auth_session_validity, 3) - allowed_oauth_flows_user_pool_client = true - allowed_oauth_flows = ["code"] - allowed_oauth_scopes = ["email", "openid", "profile", "aws.cognito.signin.user.admin"] - explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"] - supported_identity_providers = ["COGNITO"] - enable_propagate_additional_user_context_data = try(client.enable_propagate_additional_user_context_data, false) - access_token_validity = 60 - id_token_validity = try(client.id_token_validity, null) - refresh_token_validity = try(client.refresh_token_validity, null) - token_validity_units = { access_token = "minutes", id_token = "minutes", refresh_token = "days" } - prevent_user_existence_errors = try(client.prevent_user_existence_errors, null) - enable_token_revocation = try(client.enable_token_revocation, true) - } - ] -} +################################################################ +# Cognito User Pool +# +# Thin NHS wrapper around the lgallard/cognito-user-pool/aws +# community module that enforces the screening platform's +# baseline controls: +# +# * Password policy (minimum length, complexity, history) +# * Email-only auto-verification and account recovery +# * Token revocation enabled on all app clients by default +# * Schema changes ignored after initial deployment +# * Resources tagged via module.this.tags +# * Creation gated by module.this.enabled +# +# Naming and tagging are derived from context.tf via module.this. +# Derived locals (naming, client config) are defined in locals.tf. +################################################################ module "cognito" { source = "lgallard/cognito-user-pool/aws" @@ -89,11 +27,12 @@ module "cognito" { deletion_protection = var.deletion_protection auto_verified_attributes = ["email"] mfa_configuration = var.mfa_configuration + user_pool_tier = var.user_pool_tier admin_create_user_config = local.default_admin_create_user_config email_configuration = local.default_email_configuration - password_policy = local.default_password_policy - recovery_mechanisms = local.default_recovery_mechanisms + password_policy = var.password_policy + recovery_mechanisms = var.recovery_mechanisms string_schemas = local.default_string_schemas username_configuration = local.default_username_configuration verification_message_template = local.default_verification_message_template diff --git a/infrastructure/modules/cognito/outputs.tf b/infrastructure/modules/cognito/outputs.tf index 638bfc4d..5c1e573d 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -25,7 +25,7 @@ output "user_pool_domain_prefix" { output "user_pool_hosted_ui_url" { description = "Hosted UI URL for the Cognito domain when a default domain prefix is configured." - value = local.domain_name != null ? "https://${local.domain_name}.auth.${var.aws_region}.amazoncognito.com" : null + value = local.domain_name != null ? "https://${local.domain_name}.auth.${data.aws_region.current.region}.amazoncognito.com" : null } output "client_ids" { diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 582406da..3eebd825 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -1,21 +1,67 @@ -variable "create" { - description = "Determines whether Cognito resources will be created." - type = bool - default = true -} +################################################################ +# Naming overrides +################################################################ -variable "name_prefix" { - description = "Compatibility alias for older callers. Used as the default user pool and domain prefix when user_pool_name or domain are unset." +variable "user_pool_name" { + description = "Override for the Cognito user pool name. Defaults to `-users-pool`." type = string default = null } variable "domain" { - description = "Optional Cognito user pool domain prefix. Defaults to name_prefix or the resolved user pool name." + description = "Optional Cognito user pool domain prefix. Defaults to `module.this.id`." type = string default = null } +variable "app_client_name" { + description = "Override for the default Cognito app client name. Defaults to `-users-client`." + type = string + default = null +} + +################################################################ +# User pool configuration +################################################################ + +variable "user_pool_tier" { + description = "Cognito User Pool tier. LITE avoids iam:PassRole requirements from ESSENTIALS/PLUS threat-protection features. Valid values: LITE, ESSENTIALS, PLUS." + type = string + default = "LITE" + validation { + condition = contains(["LITE", "ESSENTIALS", "PLUS"], var.user_pool_tier) + error_message = "user_pool_tier must be one of: LITE, ESSENTIALS, PLUS." + } +} + +variable "recovery_mechanisms" { + description = "Account recovery mechanisms for the user pool. Defaults to email only. Avoid including verified_phone_number unless an explicit sms_configuration SNS caller role is provided — doing so forces iam:PassRole on the caller role which may be denied by restrictive IAM policies." + type = list(object({ + name = string + priority = number + })) + default = [ + { + name = "verified_email" + priority = 1 + } + ] +} + +variable "password_policy" { + description = "Password policy for the Cognito user pool." + type = object({ + minimum_length = optional(number, 8) + require_lowercase = optional(bool, true) + require_numbers = optional(bool, true) + require_symbols = optional(bool, true) + require_uppercase = optional(bool, true) + temporary_password_validity_days = optional(number, 7) + password_history_size = optional(number, 0) + }) + default = {} +} + variable "deletion_protection" { description = "Deletion protection setting for the user pool. Valid values are ACTIVE and INACTIVE." type = string @@ -44,6 +90,10 @@ variable "attribute_names" { default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bcss_username", "sid", "uid"] } +################################################################ +# Application clients +################################################################ + variable "app_clients" { description = "List of Cognito application clients to create. This wrapper intentionally supports the shared-resources OAuth client pattern rather than the full upstream clients surface." type = list(object({ @@ -54,8 +104,8 @@ variable "app_clients" { generate_secret = optional(bool, true) auth_session_validity = optional(number, 3) enable_propagate_additional_user_context_data = optional(bool, false) - id_token_validity = optional(number) - refresh_token_validity = optional(number) + id_token_validity = optional(number, 1) # hours (Cognito default unit) + refresh_token_validity = optional(number, 30) # days (Cognito default unit) prevent_user_existence_errors = optional(string) enable_token_revocation = optional(bool, true) })) @@ -70,6 +120,16 @@ variable "app_clients" { } } +################################################################ +# Bootstrap users +################################################################ + +variable "create" { + description = "Determines whether Cognito resources will be created." + type = bool + default = true +} + 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." type = list(object({