Skip to content
Open
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
49 changes: 42 additions & 7 deletions .github/workflows/optimized-infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
10 changes: 6 additions & 4 deletions docs/runbooks/cognito-email.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <notifications@domain_name>`. 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 <notifications@tryindus.ca>`.

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.
5 changes: 2 additions & 3 deletions infra/optimized/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 17 additions & 14 deletions infra/optimized/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -56,25 +55,29 @@ 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
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.
Expand Down
25 changes: 25 additions & 0 deletions infra/optimized/terraform/bootstrap/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
48 changes: 39 additions & 9 deletions infra/optimized/terraform/environment/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -347,21 +347,20 @@ 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
domain = var.public_domain_name
}
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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Reviewed production choice. This overrides the older private terraform.tfvars value.
enable_branded_cognito_email = true
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading