From 62d88e246f24766d78baa60cebab78c39e0d46f5 Mon Sep 17 00:00:00 2001 From: vicdenz Date: Fri, 16 Oct 2026 17:45:35 -0400 Subject: [PATCH 1/4] Grant optimized Terraform the SES and Cognito email permissions --- infra/optimized/terraform/bootstrap/main.tf | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/infra/optimized/terraform/bootstrap/main.tf b/infra/optimized/terraform/bootstrap/main.tf index 4810068..f9974a5 100644 --- a/infra/optimized/terraform/bootstrap/main.tf +++ b/infra/optimized/terraform/bootstrap/main.tf @@ -191,6 +191,30 @@ data "aws_iam_policy_document" "terraform" { actions = ["ssm:DescribeParameters"] resources = ["*"] } + statement { + effect = "Allow" + actions = [ + "ses:VerifyDomainIdentity", "ses:VerifyDomainDkim", + "ses:GetIdentityVerificationAttributes", "ses:GetIdentityDkimAttributes", + "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 = [ From f1d4c529b8ff567b519227f0a1dface933eb57a6 Mon Sep 17 00:00:00 2001 From: vicdenz Date: Fri, 16 Oct 2026 17:45:40 -0400 Subject: [PATCH 2/4] Enable the optimized Cognito sender through verified SES --- .../workflows/optimized-infrastructure.yml | 12 +++++++++- docs/runbooks/cognito-email.md | 8 +++---- infra/optimized/README.md | 5 ++-- infra/optimized/RUNBOOK.md | 23 ++++++++++--------- infra/optimized/terraform/environment/main.tf | 23 ++++++++++++------- .../environment/production-email.auto.tfvars | 2 ++ .../environment/terraform.tfvars.example | 3 --- .../environment/tests/optimized.tftest.hcl | 23 +++++++++++++++++-- .../terraform/environment/variables.tf | 6 ----- 9 files changed, 67 insertions(+), 38 deletions(-) create mode 100644 infra/optimized/terraform/environment/production-email.auto.tfvars diff --git a/.github/workflows/optimized-infrastructure.yml b/.github/workflows/optimized-infrastructure.yml index 9da9223..b722179 100644 --- a/.github/workflows/optimized-infrastructure.yml +++ b/.github/workflows/optimized-infrastructure.yml @@ -86,7 +86,17 @@ 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: Require production sending for SES plans + run: | + set -euo pipefail + if terraform -chdir=infra/optimized/terraform/environment show -json approved.tfplan | jq -e '.planned_values.root_module.resources[] | select(.address == "aws_cognito_user_pool.this") | .values.email_configuration[0].email_sending_account == "DEVELOPER"' >/dev/null; then + if [ "$(aws sesv2 get-account --region "${{ vars.OPTIMIZED_AWS_REGION }}" --query 'ProductionAccessEnabled' --output text)" != "True" ]; then + echo 'SES production access is required before switching Cognito to the branded 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..1b501a0 100644 --- a/docs/runbooks/cognito-email.md +++ b/docs/runbooks/cognito-email.md @@ -8,12 +8,12 @@ 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. Request SES production access and check `aws sesv2 get-account --region us-east-1 --query ProductionAccessEnabled --output text` returns `True`. Review the environment's saved plan. Terraform creates an SES identity for `tryindus.ca`, publishes ownership, DKIM, and monitoring-only DMARC DNS records in the existing public Route 53 zone, waits for identity verification, then configures the optimized Cognito pool to send the HTML code template from `Indus `. The apply job also checks SES production access before using the saved plan. Verify the SES identity and DKIM status after apply with `aws ses get-identity-verification-attributes --identities tryindus.ca --region us-east-1` and `aws ses get-identity-dkim-attributes --identities tryindus.ca --region us-east-1`; wait for DKIM success before judging inbox placement. -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. +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. Confirm Rails accepts the account after verification. ## 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..7b8f808 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 @@ -66,15 +65,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, and DMARC records +there. The bootstrap Terraform role must be updated first to grant SES identity +management and Cognito email service-role creation. Request SES production +access in `us-east-1` before applying the environment plan: the apply workflow +checks `ProductionAccessEnabled` and stops before applying the saved plan if +the account remains in the SES sandbox. The default Cognito sender remains +active until the approved 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/environment/main.tf b/infra/optimized/terraform/environment/main.tf index 65d3fac..9b84e22 100644 --- a/infra/optimized/terraform/environment/main.tf +++ b/infra/optimized/terraform/environment/main.tf @@ -348,12 +348,11 @@ resource "aws_cognito_user_pool" "this" { } 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." - } - } +} +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 @@ -378,12 +377,20 @@ resource "aws_ses_domain_dkim" "cognito" { } 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..ca24148 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,14 @@ 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 = 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 +133,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 + 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." From c9f5e4fc1189c9ccf9bf1a775a62c02aa7663d8b Mon Sep 17 00:00:00 2001 From: vicdenz Date: Fri, 16 Oct 2026 18:00:52 -0400 Subject: [PATCH 3/4] Align SES SPF and fail closed on saved-plan inspection --- .../workflows/optimized-infrastructure.yml | 10 +++++--- docs/runbooks/cognito-email.md | 4 +-- infra/optimized/RUNBOOK.md | 4 +-- infra/optimized/terraform/bootstrap/main.tf | 1 + infra/optimized/terraform/environment/main.tf | 25 ++++++++++++++++++- .../environment/tests/optimized.tftest.hcl | 6 ++++- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/.github/workflows/optimized-infrastructure.yml b/.github/workflows/optimized-infrastructure.yml index b722179..cc57694 100644 --- a/.github/workflows/optimized-infrastructure.yml +++ b/.github/workflows/optimized-infrastructure.yml @@ -91,11 +91,15 @@ jobs: - name: Require production sending for SES plans run: | set -euo pipefail - if terraform -chdir=infra/optimized/terraform/environment show -json approved.tfplan | jq -e '.planned_values.root_module.resources[] | select(.address == "aws_cognito_user_pool.this") | .values.email_configuration[0].email_sending_account == "DEVELOPER"' >/dev/null; then - if [ "$(aws sesv2 get-account --region "${{ vars.OPTIMIZED_AWS_REGION }}" --query 'ProductionAccessEnabled' --output text)" != "True" ]; then + 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 - fi + } + elif [ "$ses_sender" != 'COGNITO_DEFAULT' ]; then + echo "Unexpected Cognito sender in saved plan: $ses_sender" >&2 + exit 1 fi - name: Apply approved saved plan run: | diff --git a/docs/runbooks/cognito-email.md b/docs/runbooks/cognito-email.md index 1b501a0..aa37380 100644 --- a/docs/runbooks/cognito-email.md +++ b/docs/runbooks/cognito-email.md @@ -10,9 +10,9 @@ Cognito's verification template accepts one email body and does not expose a sep `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. -Update the optimized bootstrap Terraform role before running the environment workflow. Request SES production access and check `aws sesv2 get-account --region us-east-1 --query ProductionAccessEnabled --output text` returns `True`. Review the environment's saved plan. Terraform creates an SES identity for `tryindus.ca`, publishes ownership, DKIM, and monitoring-only DMARC DNS records in the existing public Route 53 zone, waits for identity verification, then configures the optimized Cognito pool to send the HTML code template from `Indus `. The apply job also checks SES production access before using the saved plan. Verify the SES identity and DKIM status after apply with `aws ses get-identity-verification-attributes --identities tryindus.ca --region us-east-1` and `aws ses get-identity-dkim-attributes --identities tryindus.ca --region us-east-1`; wait for DKIM success before judging inbox placement. +Update the optimized bootstrap Terraform role before running the environment workflow. Request SES production access and check `aws sesv2 get-account --region us-east-1 --query ProductionAccessEnabled --output text` returns `True`. Review the environment's saved plan. Terraform creates an SES identity for `tryindus.ca`, publishes ownership, DKIM, monitoring-only DMARC, and custom MAIL FROM DNS records in the existing public Route 53 zone, waits for identity verification, then configures the optimized Cognito pool to send the HTML code template from `Indus `. The MAIL FROM subdomain `bounce.tryindus.ca` gives SES an SPF-aligned Return-Path once verified; SES uses its default MAIL FROM while the custom domain is pending. The apply job also checks SES production access before using the saved plan. Verify the identity, DKIM, and MAIL FROM status after apply 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`; wait for DKIM and MAIL FROM success before judging inbox placement. -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. Confirm Rails accepts the account after verification. +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 diff --git a/infra/optimized/RUNBOOK.md b/infra/optimized/RUNBOOK.md index 7b8f808..a82ebce 100644 --- a/infra/optimized/RUNBOOK.md +++ b/infra/optimized/RUNBOOK.md @@ -68,8 +68,8 @@ accounts. `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, and DMARC records -there. The bootstrap Terraform role must be updated first to grant SES identity +hosted zone and creates the SES identity, ownership, DKIM, DMARC, and aligned +MAIL FROM records there. The bootstrap Terraform role must be updated first to grant SES identity management and Cognito email service-role creation. Request SES production access in `us-east-1` before applying the environment plan: the apply workflow checks `ProductionAccessEnabled` and stops before applying the saved plan if diff --git a/infra/optimized/terraform/bootstrap/main.tf b/infra/optimized/terraform/bootstrap/main.tf index f9974a5..7540962 100644 --- a/infra/optimized/terraform/bootstrap/main.tf +++ b/infra/optimized/terraform/bootstrap/main.tf @@ -196,6 +196,7 @@ data "aws_iam_policy_document" "terraform" { actions = [ "ses:VerifyDomainIdentity", "ses:VerifyDomainDkim", "ses:GetIdentityVerificationAttributes", "ses:GetIdentityDkimAttributes", + "ses:SetIdentityMailFromDomain", "ses:GetIdentityMailFromDomainAttributes", "ses:DeleteIdentity", "ses:GetAccount" ] resources = ["*"] diff --git a/infra/optimized/terraform/environment/main.tf b/infra/optimized/terraform/environment/main.tf index 9b84e22..1e38640 100644 --- a/infra/optimized/terraform/environment/main.tf +++ b/infra/optimized/terraform/environment/main.tf @@ -347,7 +347,7 @@ 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] + 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 @@ -375,6 +375,29 @@ 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 = data.aws_route53_zone.branded_email[0].zone_id diff --git a/infra/optimized/terraform/environment/tests/optimized.tftest.hcl b/infra/optimized/terraform/environment/tests/optimized.tftest.hcl index ca24148..6a82f55 100644 --- a/infra/optimized/terraform/environment/tests/optimized.tftest.hcl +++ b/infra/optimized/terraform/environment/tests/optimized.tftest.hcl @@ -123,6 +123,10 @@ run "branded_cognito_confirmation_email_uses_ses" { 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." @@ -139,7 +143,7 @@ run "default_cognito_sender_can_be_restored" { enable_branded_cognito_email = false } assert { - condition = length(aws_cognito_user_pool.this.email_configuration) == 0 && length(aws_ses_domain_identity.cognito) == 0 + 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." } } From c23fc9040c86ad313dd64c0db23241a0acb780b6 Mon Sep 17 00:00:00 2001 From: vicdenz Date: Fri, 16 Oct 2026 18:04:44 -0400 Subject: [PATCH 4/4] Stage SES DNS verification before the Cognito sender switch --- .../workflows/optimized-infrastructure.yml | 51 +++++++++++++------ docs/runbooks/cognito-email.md | 4 +- infra/optimized/RUNBOOK.md | 20 ++++---- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/.github/workflows/optimized-infrastructure.yml b/.github/workflows/optimized-infrastructure.yml index cc57694..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 @@ -88,18 +97,30 @@ jobs: 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: Require production sending for SES plans + - name: Check the approved email rollout stage run: | set -euo pipefail - 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 + 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 - } - 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: | diff --git a/docs/runbooks/cognito-email.md b/docs/runbooks/cognito-email.md index aa37380..9ae50a4 100644 --- a/docs/runbooks/cognito-email.md +++ b/docs/runbooks/cognito-email.md @@ -10,7 +10,9 @@ Cognito's verification template accepts one email body and does not expose a sep `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. -Update the optimized bootstrap Terraform role before running the environment workflow. Request SES production access and check `aws sesv2 get-account --region us-east-1 --query ProductionAccessEnabled --output text` returns `True`. Review the environment's saved plan. Terraform creates an SES identity for `tryindus.ca`, publishes ownership, DKIM, monitoring-only DMARC, and custom MAIL FROM DNS records in the existing public Route 53 zone, waits for identity verification, then configures the optimized Cognito pool to send the HTML code template from `Indus `. The MAIL FROM subdomain `bounce.tryindus.ca` gives SES an SPF-aligned Return-Path once verified; SES uses its default MAIL FROM while the custom domain is pending. The apply job also checks SES production access before using the saved plan. Verify the identity, DKIM, and MAIL FROM status after apply 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`; wait for DKIM and MAIL FROM success before judging inbox placement. +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. + +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. diff --git a/infra/optimized/RUNBOOK.md b/infra/optimized/RUNBOOK.md index a82ebce..1e3c746 100644 --- a/infra/optimized/RUNBOOK.md +++ b/infra/optimized/RUNBOOK.md @@ -55,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 @@ -69,12 +71,12 @@ accounts. 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. The bootstrap Terraform role must be updated first to grant SES identity -management and Cognito email service-role creation. Request SES production -access in `us-east-1` before applying the environment plan: the apply workflow -checks `ProductionAccessEnabled` and stops before applying the saved plan if -the account remains in the SES sandbox. The default Cognito sender remains -active until the approved environment apply succeeds. See the +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