diff --git a/.github/workflows/optimized-infrastructure.yml b/.github/workflows/optimized-infrastructure.yml index 9da9223..fa28257 100644 --- a/.github/workflows/optimized-infrastructure.yml +++ b/.github/workflows/optimized-infrastructure.yml @@ -4,10 +4,10 @@ on: workflow_dispatch: inputs: operation: - description: Plan only, or plan and request approval to apply + description: Plan only, prepare SES DNS, or plan and request approval to apply required: true type: choice - options: [plan, apply] + options: [plan, prepare-email, apply] concurrency: group: optimized-infrastructure @@ -47,10 +47,19 @@ jobs: run: | set -euo pipefail terraform -chdir=infra/optimized/terraform/environment init -input=false -lockfile=readonly -backend-config=backend.hcl - terraform -chdir=infra/optimized/terraform/environment plan -input=false -out=approved.tfplan + if [ "${{ inputs.operation }}" = 'prepare-email' ]; then + terraform -chdir=infra/optimized/terraform/environment plan -input=false -out=approved.tfplan \ + -target=aws_ses_domain_identity_verification.cognito \ + -target=aws_route53_record.cognito_dkim \ + -target=aws_route53_record.cognito_mail_from_mx \ + -target=aws_route53_record.cognito_mail_from_spf \ + -target=aws_route53_record.cognito_dmarc + else + terraform -chdir=infra/optimized/terraform/environment plan -input=false -out=approved.tfplan + fi terraform -chdir=infra/optimized/terraform/environment show -no-color approved.tfplan >> "$GITHUB_STEP_SUMMARY" - name: Preserve exact plan for approved apply - if: inputs.operation == 'apply' + if: inputs.operation == 'apply' || inputs.operation == 'prepare-email' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: optimized-approved-plan @@ -59,11 +68,11 @@ jobs: retention-days: 7 apply: - if: inputs.operation == 'apply' && github.ref == 'refs/heads/main' + if: (inputs.operation == 'apply' || inputs.operation == 'prepare-email') && github.ref == 'refs/heads/main' needs: plan environment: optimized-production runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 75 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 @@ -86,7 +95,33 @@ jobs: with: name: optimized-approved-plan path: infra/optimized/terraform/environment + - name: Initialize optimized Terraform + run: terraform -chdir=infra/optimized/terraform/environment init -input=false -lockfile=readonly -backend-config=backend.hcl + - name: Check the approved email rollout stage + run: | + set -euo pipefail + if [ "${{ inputs.operation }}" = 'prepare-email' ]; then + terraform -chdir=infra/optimized/terraform/environment show -json approved.tfplan | jq -e 'all(.resource_changes[]?; .address != "aws_cognito_user_pool.this" or .change.actions == ["no-op"])' >/dev/null + else + ses_sender="$(terraform -chdir=infra/optimized/terraform/environment show -json approved.tfplan | jq -er '.planned_values.root_module.resources[] | select(.address == "aws_cognito_user_pool.this") | .values.email_configuration[0].email_sending_account // "COGNITO_DEFAULT"')" + if [ "$ses_sender" = 'DEVELOPER' ]; then + test "$(aws sesv2 get-account --region "${{ vars.OPTIMIZED_AWS_REGION }}" --query 'ProductionAccessEnabled' --output text)" = 'True' || { + echo 'SES production access is required before switching Cognito to the branded sender.' >&2 + exit 1 + } + test "$(aws ses get-identity-dkim-attributes --region "${{ vars.OPTIMIZED_AWS_REGION }}" --identities tryindus.ca --query 'DkimAttributes."tryindus.ca".DkimVerificationStatus' --output text)" = 'Success' || { + echo 'The tryindus.ca SES DKIM identity must be verified before switching Cognito.' >&2 + exit 1 + } + test "$(aws ses get-identity-mail-from-domain-attributes --region "${{ vars.OPTIMIZED_AWS_REGION }}" --identities tryindus.ca --query 'MailFromDomainAttributes."tryindus.ca".MailFromDomainStatus' --output text)" = 'Success' || { + echo 'The bounce.tryindus.ca MAIL FROM domain must be verified before switching Cognito.' >&2 + exit 1 + } + elif [ "$ses_sender" != 'COGNITO_DEFAULT' ]; then + echo "Unexpected Cognito sender in saved plan: $ses_sender" >&2 + exit 1 + fi + fi - name: Apply approved saved plan run: | - terraform -chdir=infra/optimized/terraform/environment init -input=false -lockfile=readonly -backend-config=backend.hcl terraform -chdir=infra/optimized/terraform/environment apply -input=false approved.tfplan diff --git a/docs/runbooks/cognito-email.md b/docs/runbooks/cognito-email.md index dd0715a..9ae50a4 100644 --- a/docs/runbooks/cognito-email.md +++ b/docs/runbooks/cognito-email.md @@ -8,12 +8,14 @@ Cognito's verification template accepts one email body and does not expose a sep ## Enable the branded sender -`enable_branded_cognito_email` defaults to `false` in each Terraform environment. Before changing it, confirm that the environment's SES account in `us-east-1` can send to unverified recipients. SES sandbox accounts cannot deliver signup codes to arbitrary users. Check the current SES plan and sending limits; the Essentials plan has usage charges but no fixed monthly plan fee. Do not enable paid SES add-ons as part of this change. +`enable_branded_cognito_email` defaults to `false` in Terraform, but the optimized production profile sets it to `true` in its tracked `production-email.auto.tfvars`. Terraform loads that file after the private `terraform.tfvars`, including in the GitHub infrastructure workflow. Before applying, confirm that the environment's SES account in `us-east-1` can send to unverified recipients. SES sandbox accounts cannot deliver signup codes to arbitrary users. Check the current SES plan and sending limits; the Essentials plan has usage charges but no fixed monthly plan fee. Do not enable paid SES add-ons as part of this change. -Set `enable_branded_cognito_email = true` in the target environment's private Terraform variables and review its plan. Terraform creates an SES identity for `domain_name`, publishes the ownership and DKIM DNS records in the existing Route 53 zone, waits for identity verification, then configures the Cognito user pool to send the HTML code template from `Indus `. Apply through the established infrastructure workflow, staging first. Verify the SES identity and DKIM status after apply. +Update the optimized bootstrap Terraform role before running the environment workflow. Dispatch `prepare-email` on `main` and review its saved plan. It creates the SES identity for `tryindus.ca` and publishes ownership, DKIM, monitoring-only DMARC, and custom MAIL FROM DNS records in the existing public Route 53 zone without changing Cognito. Verify the identity, DKIM, and MAIL FROM status with `aws ses get-identity-verification-attributes --identities tryindus.ca --region us-east-1`, `aws ses get-identity-dkim-attributes --identities tryindus.ca --region us-east-1`, and `aws ses get-identity-mail-from-domain-attributes --identities tryindus.ca --region us-east-1`. The MAIL FROM subdomain `bounce.tryindus.ca` gives SES an SPF-aligned Return-Path once verified. -In staging, complete signup, wrong-code, expired-code, resend, unconfirmed sign-in, and password-reset checks with a real mailbox that is not an SES verified identity. Check delivery time, inbox and spam placement, sender domain, and the email layout in desktop and mobile clients. Confirm Rails accepts the account after verification. Repeat the smoke check in production during an approved deployment window. +Request SES production access in `us-east-1` with the verified domain, then check `aws sesv2 get-account --region us-east-1 --query ProductionAccessEnabled --output text` returns `True`. Dispatch the full `apply` and review its saved plan. Its preflight refuses to switch Cognito until production access, DKIM, and MAIL FROM all show success. The pool then sends the HTML code template from `Indus `. + +After deployment, complete signup, wrong-code, expired-code, resend, unconfirmed sign-in, and password-reset checks with a real mailbox that is not an SES verified identity. Check delivery time, inbox and spam placement, sender domain, and the email layout in desktop and mobile clients. Inspect the received message headers: SPF should pass for `bounce.tryindus.ca`, DKIM should pass for `tryindus.ca`, and DMARC should pass for the visible From domain. Confirm Rails accepts the account after verification. Authentication improves deliverability but does not guarantee inbox placement; use recipient feedback and reputation data to investigate any remaining spam classification. ## Recovery -If branded delivery fails, disable `enable_branded_cognito_email` through a reviewed Terraform plan and apply. This returns Cognito to its default sender and template without changing existing account states. Users with an unconfirmed account can request a new code from the Indus sign-in screen. Do not manually mark email addresses verified merely to bypass a delivery problem; Rails requires a verified email for API access. +If branded delivery fails, set `enable_branded_cognito_email = false` in `production-email.auto.tfvars`, review the plan, and apply through the optimized workflow. Changing the private `terraform.tfvars` alone will not override the tracked auto file. This returns Cognito to its default sender and template without changing existing account states. Users with an unconfirmed account can request a new code from the Indus sign-in screen. Do not manually mark email addresses verified merely to bypass a delivery problem; Rails requires a verified email for API access. diff --git a/infra/optimized/README.md b/infra/optimized/README.md index e117e11..748eedf 100644 --- a/infra/optimized/README.md +++ b/infra/optimized/README.md @@ -22,9 +22,8 @@ approved load test. The host retains an Elastic IP because the production apex record points directly to it. There is no separate `optimized.tryindus.ca` hosted zone or -endpoint. If branded Cognito email is enabled later, supply the existing -`tryindus.ca` hosted-zone ID through `branded_email_zone_id`; do not create a -second hosted zone. +endpoint. Branded Cognito email uses a Route 53 lookup of the existing +`tryindus.ca` public hosted zone; it does not create a second hosted zone. Bootstrap starts with local state because it creates the backend bucket. After an operator has reviewed and applied it, migrate bootstrap state to the bucket diff --git a/infra/optimized/RUNBOOK.md b/infra/optimized/RUNBOOK.md index 7bbde12..1e3c746 100644 --- a/infra/optimized/RUNBOOK.md +++ b/infra/optimized/RUNBOOK.md @@ -44,8 +44,7 @@ interrupts service. Record the bucket, KMS key, optimized ECR repositories, Terraform role, and release role. Do not reuse main's state bucket or state key. 4. Copy the environment backend and variables examples to ignored files. Select - a VPC CIDR that does not overlap main, a branded-email domain only if that - optional feature will be enabled, two AZs, and optimized bootstrap ECR ARNs. + a VPC CIDR that does not overlap main, two AZs, and optimized bootstrap ECR ARNs. Use the second RDS subnet for the DB subnet group only. Review the saved plan before apply. The production hostname is `tryindus.ca`, which points directly to the host Elastic IP; this profile does not create or require a @@ -56,9 +55,11 @@ interrupts service. optimized plan and apply role ARNs as `OPTIMIZED_PLAN_ROLE_ARN` and `OPTIMIZED_TERRAFORM_ROLE_ARN` respectively. Dispatch the manual `optimized-infrastructure` workflow from `main`. `plan` only reviews; - `apply` first saves an exact plan, publishes its readable form in the job - summary, then waits for approval of `optimized-production` before applying - that saved plan. Both jobs require the optimized state bucket and key. + `prepare-email` plans and applies only the SES identity and DNS dependencies; + `apply` reconciles the full environment after SES is ready. Both mutations + save an exact plan, publish its readable form in the job summary, and wait + for approval of `optimized-production` before applying it. Both jobs require + the optimized state bucket and key. The optimized web image uses the same `/auth` confirmation and recovery experience as the main application: signup codes, recoverable unconfirmed @@ -66,15 +67,17 @@ accounts, resend cooldowns, and password-reset codes. Cognito MFA remains off. The optimized pool has its own users and issuer; it does not inherit main-pool accounts. -`enable_branded_cognito_email` defaults to `false`. Setting it to `true` in the -private optimized Terraform variables configures Cognito to send the Indus HTML -verification template through SES. Set `branded_email_zone_id` to the existing -`tryindus.ca` hosted-zone ID before enabling it; Terraform then creates the SES -identity and DKIM records there without creating a second hosted zone. Confirm -SES can send to arbitrary recipients in this region. SES may otherwise remain -in its sandbox, where signup delivery is restricted. Keep the flag false until -those prerequisites are ready; Cognito's standard sender continues to work -meanwhile. +`production-email.auto.tfvars` enables branded Cognito email for this production +profile and overrides any older `enable_branded_cognito_email = false` in the +private `terraform.tfvars`. Terraform looks up the existing public `tryindus.ca` +hosted zone and creates the SES identity, ownership, DKIM, DMARC, and aligned +MAIL FROM records there. Update the bootstrap Terraform role first, then run +`prepare-email` and wait for the domain identity, DKIM, and MAIL FROM status to +be successful. Request SES production access in `us-east-1` with the verified +domain. The full `apply` checks those statuses and `ProductionAccessEnabled` +before switching Cognito. The default Cognito sender remains active until the +approved full environment apply succeeds. See the +[email runbook](../../docs/runbooks/cognito-email.md) for verification and rollback. The optimized bootstrap creates its own plan, apply, release, and host IAM roles. These are roles in the configured AWS account, not new human IAM users. diff --git a/infra/optimized/terraform/bootstrap/main.tf b/infra/optimized/terraform/bootstrap/main.tf index 4810068..7540962 100644 --- a/infra/optimized/terraform/bootstrap/main.tf +++ b/infra/optimized/terraform/bootstrap/main.tf @@ -191,6 +191,31 @@ data "aws_iam_policy_document" "terraform" { actions = ["ssm:DescribeParameters"] resources = ["*"] } + statement { + effect = "Allow" + actions = [ + "ses:VerifyDomainIdentity", "ses:VerifyDomainDkim", + "ses:GetIdentityVerificationAttributes", "ses:GetIdentityDkimAttributes", + "ses:SetIdentityMailFromDomain", "ses:GetIdentityMailFromDomainAttributes", + "ses:DeleteIdentity", "ses:GetAccount" + ] + resources = ["*"] + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [var.aws_region] + } + } + statement { + effect = "Allow" + actions = ["iam:CreateServiceLinkedRole"] + resources = ["arn:${data.aws_partition.current.partition}:iam::${var.account_id}:role/aws-service-role/email.cognito-idp.amazonaws.com/AWSServiceRoleForAmazonCognitoIdpEmailService"] + condition { + test = "StringEquals" + variable = "iam:AWSServiceName" + values = ["email.cognito-idp.amazonaws.com"] + } + } statement { effect = "Allow" actions = [ diff --git a/infra/optimized/terraform/environment/main.tf b/infra/optimized/terraform/environment/main.tf index 65d3fac..1e38640 100644 --- a/infra/optimized/terraform/environment/main.tf +++ b/infra/optimized/terraform/environment/main.tf @@ -347,13 +347,12 @@ resource "aws_cognito_user_pool" "this" { temporary_password_validity_days = 3 } tags = local.common_tags - depends_on = [aws_ses_domain_identity_verification.cognito, aws_route53_record.cognito_dkim] - lifecycle { - precondition { - condition = !var.enable_branded_cognito_email || var.branded_email_zone_id != null - error_message = "Set branded_email_zone_id to an existing hosted zone before enabling branded Cognito email." - } - } + depends_on = [aws_ses_domain_identity_verification.cognito, aws_route53_record.cognito_dkim, aws_route53_record.cognito_mail_from_mx, aws_route53_record.cognito_mail_from_spf] +} +data "aws_route53_zone" "branded_email" { + count = var.enable_branded_cognito_email ? 1 : 0 + name = "${var.public_domain_name}." + private_zone = false } resource "aws_ses_domain_identity" "cognito" { count = var.enable_branded_cognito_email ? 1 : 0 @@ -361,7 +360,7 @@ resource "aws_ses_domain_identity" "cognito" { } resource "aws_route53_record" "cognito_ses_verification" { count = var.enable_branded_cognito_email ? 1 : 0 - zone_id = var.branded_email_zone_id + zone_id = data.aws_route53_zone.branded_email[0].zone_id name = "_amazonses.${var.public_domain_name}" type = "TXT" ttl = 300 @@ -376,14 +375,45 @@ resource "aws_ses_domain_dkim" "cognito" { count = var.enable_branded_cognito_email ? 1 : 0 domain = aws_ses_domain_identity.cognito[0].domain } +resource "aws_ses_domain_mail_from" "cognito" { + count = var.enable_branded_cognito_email ? 1 : 0 + domain = aws_ses_domain_identity.cognito[0].domain + mail_from_domain = "bounce.${var.public_domain_name}" + behavior_on_mx_failure = "UseDefaultValue" + depends_on = [aws_ses_domain_identity_verification.cognito] +} +resource "aws_route53_record" "cognito_mail_from_mx" { + count = var.enable_branded_cognito_email ? 1 : 0 + zone_id = data.aws_route53_zone.branded_email[0].zone_id + name = aws_ses_domain_mail_from.cognito[0].mail_from_domain + type = "MX" + ttl = 300 + records = ["10 feedback-smtp.${var.aws_region}.amazonses.com"] +} +resource "aws_route53_record" "cognito_mail_from_spf" { + count = var.enable_branded_cognito_email ? 1 : 0 + zone_id = data.aws_route53_zone.branded_email[0].zone_id + name = aws_ses_domain_mail_from.cognito[0].mail_from_domain + type = "TXT" + ttl = 300 + records = ["v=spf1 include:amazonses.com ~all"] +} resource "aws_route53_record" "cognito_dkim" { count = var.enable_branded_cognito_email ? 3 : 0 - zone_id = var.branded_email_zone_id + zone_id = data.aws_route53_zone.branded_email[0].zone_id name = "${aws_ses_domain_dkim.cognito[0].dkim_tokens[count.index]}._domainkey.${var.public_domain_name}" type = "CNAME" ttl = 300 records = ["${aws_ses_domain_dkim.cognito[0].dkim_tokens[count.index]}.dkim.amazonses.com"] } +resource "aws_route53_record" "cognito_dmarc" { + count = var.enable_branded_cognito_email ? 1 : 0 + zone_id = data.aws_route53_zone.branded_email[0].zone_id + name = "_dmarc.${var.public_domain_name}" + type = "TXT" + ttl = 300 + records = ["v=DMARC1; p=none"] +} resource "aws_cognito_user_pool_client" "web" { name = "web" user_pool_id = aws_cognito_user_pool.this.id diff --git a/infra/optimized/terraform/environment/production-email.auto.tfvars b/infra/optimized/terraform/environment/production-email.auto.tfvars new file mode 100644 index 0000000..0acfa98 --- /dev/null +++ b/infra/optimized/terraform/environment/production-email.auto.tfvars @@ -0,0 +1,2 @@ +# Reviewed production choice. This overrides the older private terraform.tfvars value. +enable_branded_cognito_email = true diff --git a/infra/optimized/terraform/environment/terraform.tfvars.example b/infra/optimized/terraform/environment/terraform.tfvars.example index ac943ef..49d756a 100644 --- a/infra/optimized/terraform/environment/terraform.tfvars.example +++ b/infra/optimized/terraform/environment/terraform.tfvars.example @@ -7,9 +7,6 @@ cognito_logout_urls = ["https://optimized.indus.example.com/auth"] alert_email_addresses = ["on-call@example.com"] monthly_budget_usd = 100 enable_supplementary_monitoring = false -# Required only when enable_branded_cognito_email is true. Use the existing -# tryindus.ca hosted zone rather than creating a second hosted zone. -# branded_email_zone_id = "Z0123456789EXAMPLE" ecr_repository_arns = { platform-api = "arn:aws:ecr:us-east-1:111111111111:repository/indus-optimized/platform-api" market-data = "arn:aws:ecr:us-east-1:111111111111:repository/indus-optimized/market-data" diff --git a/infra/optimized/terraform/environment/tests/optimized.tftest.hcl b/infra/optimized/terraform/environment/tests/optimized.tftest.hcl index bc1f410..6a82f55 100644 --- a/infra/optimized/terraform/environment/tests/optimized.tftest.hcl +++ b/infra/optimized/terraform/environment/tests/optimized.tftest.hcl @@ -4,6 +4,7 @@ mock_provider "aws" { mock_data "aws_partition" { defaults = { partition = "aws" } } mock_data "aws_caller_identity" { defaults = { account_id = "111111111111" } } mock_data "aws_ssm_parameter" { defaults = { value = "ami-test" } } + mock_data "aws_route53_zone" { defaults = { zone_id = "Z0123456789EXAMPLE" } } mock_data "aws_iam_policy_document" { defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" @@ -101,11 +102,10 @@ run "supplementary_monitoring_can_be_restored" { } } -run "branded_cognito_confirmation_email_is_opt_in" { +run "branded_cognito_confirmation_email_uses_ses" { command = plan variables { enable_branded_cognito_email = true - branded_email_zone_id = "Z0123456789EXAMPLE" } assert { condition = aws_cognito_user_pool.this.email_configuration[0].email_sending_account == "DEVELOPER" @@ -115,6 +115,18 @@ run "branded_cognito_confirmation_email_is_opt_in" { condition = aws_ses_domain_identity.cognito[0].domain == "tryindus.ca" error_message = "Branded email must use the existing production domain." } + assert { + condition = aws_route53_record.cognito_ses_verification[0].zone_id == data.aws_route53_zone.branded_email[0].zone_id + error_message = "SES verification must use the existing public hosted zone." + } + assert { + condition = contains(aws_route53_record.cognito_dmarc[0].records, "v=DMARC1; p=none") + error_message = "The sender domain must publish a monitoring-only DMARC policy." + } + assert { + condition = aws_ses_domain_mail_from.cognito[0].mail_from_domain == "bounce.tryindus.ca" && contains(aws_route53_record.cognito_mail_from_spf[0].records, "v=spf1 include:amazonses.com ~all") + error_message = "The sender must use an aligned custom MAIL FROM domain with SES SPF." + } assert { condition = strcontains(aws_cognito_user_pool.this.verification_message_template[0].email_message, "{####}") error_message = "The branded verification template must include Cognito's confirmation code placeholder." @@ -125,6 +137,17 @@ run "branded_cognito_confirmation_email_is_opt_in" { } } +run "default_cognito_sender_can_be_restored" { + command = plan + variables { + enable_branded_cognito_email = false + } + assert { + condition = length(aws_cognito_user_pool.this.email_configuration) == 0 && length(aws_ses_domain_identity.cognito) == 0 && length(aws_ses_domain_mail_from.cognito) == 0 + error_message = "Disabling the production switch must restore Cognito's default sender." + } +} + run "reject_main_vpc_range" { command = plan variables { vpc_cidr = "10.40.0.0/16" } diff --git a/infra/optimized/terraform/environment/variables.tf b/infra/optimized/terraform/environment/variables.tf index 41663a9..b6567ab 100644 --- a/infra/optimized/terraform/environment/variables.tf +++ b/infra/optimized/terraform/environment/variables.tf @@ -70,12 +70,6 @@ variable "enable_branded_cognito_email" { type = bool default = false } -variable "branded_email_zone_id" { - type = string - default = null - nullable = true - description = "Existing Route 53 hosted-zone ID for branded Cognito email DNS records. Required only when branded email is enabled." -} variable "ecr_repository_arns" { type = map(string) description = "Optimized bootstrap ECR repository ARNs. Supply as plain, non-secret configuration."