├── .nvmrc ├── .terraform-version ├── .python-version ├── terraform ├── 20-app │ ├── etc │ │ ├── dev.tfvars │ │ ├── test.tfvars │ │ ├── prod.tfvars │ │ └── uat.tfvars │ ├── s3.access-logs.tf │ ├── s3.elb-logs.tf │ ├── ecs.tf │ ├── s3.vpc-flow-logs.tf │ ├── s3.cloud-front-logs.tf │ ├── kinesis-data-stream.tf │ ├── cloudwatch.rum.front-end.tf │ ├── sns.alarms.tf │ ├── s3.waf-logs.tf │ ├── versions.tf │ ├── waf.ip-allow-set.tf │ ├── state.account-layer.tf │ ├── shield-advanced.tf │ ├── passwords.tf │ ├── ecs.jobs.hydrate-private-api-cache.tf │ ├── sns.cloudfront-alarms.tf │ ├── ecs-jobs │ │ ├── hydrate-private-api-cache.tftpl │ │ ├── hydrate-frontend-cache.tftpl │ │ ├── hydrate-public-api-cache.tftpl │ │ └── bootstrap-env.tftpl │ ├── s3.canary-logs.tf │ ├── vars.tf │ ├── ecs.jobs.hydrate-frontend-cache.tf │ ├── ecs.jobs.hydrate-public-api-cache.tf │ ├── ecs.jobs.bootstrap-env.tf │ ├── cloudwatch.alarms.aurora-db.tf │ ├── main.tf │ ├── ecr.tf │ ├── eventbridge.tf │ ├── elasticache.tf │ ├── s3.archive-web-content.tf │ ├── vpc.tf │ ├── lambda.producer.tf │ ├── route-53.legacy-dashboard-redirect.tf │ ├── cloudwatch.dashboards.tf │ ├── aurora-db.feature-flags.tf │ ├── api-keys.tf │ ├── cloudwatch.alarms.tf │ ├── lambda.db-password-rotation.tf │ ├── lambda.alarm-notification.tf │ ├── kms.tf │ ├── alb.cms-admin.tf │ ├── ecs.service.bastion.tf │ ├── route-53.wke-account.tf │ ├── route-53.wke-others.tf │ ├── route-53.tf │ ├── cloud-front.legacy-dashboard-redirect.tf │ ├── waf.front-end.tf │ ├── ip-allow-lists.tf │ ├── outputs.tf │ ├── alb.front-end.tf │ ├── alb.public-api.tf │ ├── alb.private-api.tf │ ├── alb.feature-flags.tf │ ├── s3.ingest.tf │ ├── alb.feedback-api.tf │ ├── waf.cms.tf │ ├── waf.public-api.tf │ ├── waf.feature-flags.tf │ ├── waf.archive-web-content.tf │ ├── aurora-db.app.tf │ ├── waf.legacy-dashboard-redirect.tf │ ├── lambda.ingestion.tf │ └── cloud-front.archive-web-content.tf ├── 10-account │ ├── sts.tf │ ├── etc │ │ ├── prod.tfvars │ │ ├── dev.tfvars │ │ ├── test.tfvars │ │ └── uat.tfvars │ ├── secrets-manager.tf │ ├── versions.tf │ ├── s3.account-settings.tf │ ├── s3.athena-query-results.tf │ ├── route-53.wke-uat-account.tf │ ├── route-53.account.tf │ ├── route-53.wke-test-account.tf │ ├── acm.account.tf │ ├── vars.tf │ ├── sqs.sentinel-alb-access-logs.tf │ ├── iam.terraform-role.tf │ ├── acm.wke-uat-account.tf │ ├── iam.shield-advanced-role.tf │ ├── kms.tf │ ├── s3.access-logs.tf │ ├── iam.developer-role.tf │ ├── locals.tf │ ├── iam.report-viewer-role.tf │ ├── acm.cloud-front.wke-uat-account.tf │ ├── cur.tf │ ├── shield-advanced.tf │ ├── s3.alb-logs.tf │ ├── main.tf │ ├── iam.sso-roles.tf │ ├── acm.cloud-front.tf │ ├── acm.wke-test-account.tf │ ├── s3.cloud-front-logs.tf │ ├── state.account-layer.tf │ ├── acm.cloud-front.wke-test-account.tf │ ├── iam.sentinel-role.tf │ ├── .terraform.lock.hcl │ ├── iam.green-ops-s3-replication-role.tf │ ├── s3.vpc-flow-logs.tf │ ├── route-53.prod-account.tf │ ├── s3.cur-exports.tf │ ├── iam.operations-role.tf │ └── outputs.tf └── modules │ ├── cloud-front-basic-password-protection │ ├── cloudfront.kv-store.tf │ ├── outputs.tf │ ├── versions.tf │ ├── main.tf │ ├── vars.tf │ └── cloudfront.function.tf │ ├── ecr │ ├── data.aws_ecr_image.tf │ ├── outputs.tf │ ├── local.tf │ ├── provisioner.initial-image.tf │ ├── ecr.tf │ └── vars.tf │ ├── cloudwatch-rum │ ├── versions.tf │ ├── cloudwatch.rum.tf │ ├── cognito.tf │ ├── outputs.tf │ ├── vars.tf │ └── iam.tf │ └── cloud-watch-canary │ ├── versions.tf │ ├── script.tf │ ├── security-group.tf │ ├── cloudwatch.synthetics-canary.tf │ ├── vars.tf │ └── iam.tf ├── src ├── public-api-cloud-front-viewer-request │ ├── babel.config.json │ ├── jest.config.js │ ├── package.json │ ├── index.js │ └── index.test.js ├── legacy-dashboard-redirect-viewer-request │ ├── babel.config.json │ ├── jest.config.js │ ├── package.json │ ├── index.js │ └── index.test.js ├── lambda-alarm-notification │ ├── jest.config.js │ └── package.json ├── lambda-producer-handler │ ├── jest.config.js │ └── package.json └── lambda-db-password-rotation │ ├── jest.config.js │ └── package.json ├── .github ├── actions │ ├── setup-zsh │ │ └── action.yml │ ├── short-sha │ │ └── action.yml │ ├── setup-terraform │ │ └── action.yml │ ├── trigger-smoke-tests │ │ └── action.yml │ ├── well-known-environment-name │ │ └── action.yml │ ├── npm-test │ │ └── action.yml │ └── configure-aws-credentials │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── cleanup-ci-test-environments.yml │ └── flush-caches.yml ├── scripts ├── _update.sh ├── fast-forward-env-branches.sh ├── _data.sh ├── _gh.sh ├── _secrets.sh ├── _lambda.sh └── _aws.sh ├── sonar-project.properties ├── cloud-watch └── log-insights │ └── public-api.viewer-request.headers.query ├── LICENSE ├── athena └── tables │ ├── front-end-cloud-front-logs.sql │ ├── public-api-cloud-front-logs.sql │ ├── cms-admin-alb-access-logs.sql │ ├── front-end-alb-access-logs.sql │ ├── public-api-alb-access-logs.sql │ └── private-api-alb-access-logs.sql └── uhd.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.terraform-version: -------------------------------------------------------------------------------- 1 | 1.7.5 -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /terraform/20-app/etc/dev.tfvars: -------------------------------------------------------------------------------- 1 | environment_type = "dev" 2 | halo_account_type = "nl1" 3 | -------------------------------------------------------------------------------- /terraform/20-app/etc/test.tfvars: -------------------------------------------------------------------------------- 1 | environment_type = "test" 2 | halo_account_type = "nl2" 3 | -------------------------------------------------------------------------------- /src/public-api-cloud-front-viewer-request/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["babel-plugin-rewire"] 3 | } 4 | -------------------------------------------------------------------------------- /src/legacy-dashboard-redirect-viewer-request/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["babel-plugin-rewire"] 3 | } 4 | -------------------------------------------------------------------------------- /terraform/10-account/sts.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "current" {} 2 | 3 | locals { 4 | account_id = data.aws_caller_identity.current.account_id 5 | } 6 | -------------------------------------------------------------------------------- /terraform/20-app/s3.access-logs.tf: -------------------------------------------------------------------------------- 1 | data "aws_s3_bucket" "s3_access_logs" { 2 | bucket = "uhd-aws-s3-access-logs-${local.account_id}-${local.region}" 3 | } 4 | -------------------------------------------------------------------------------- /terraform/20-app/s3.elb-logs.tf: -------------------------------------------------------------------------------- 1 | data "aws_s3_bucket" "elb_logs_eu_west_2" { 2 | bucket = "uhd-aws-elb-access-logs-${local.account_id}-${local.region}" 3 | } 4 | -------------------------------------------------------------------------------- /src/lambda-alarm-notification/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reporters: ["default", "jest-junit"], 3 | coverageReporters: ["json-summary", "text"], 4 | }; 5 | -------------------------------------------------------------------------------- /src/lambda-producer-handler/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reporters: ["default", "jest-junit"], 3 | coverageReporters: ["json-summary", "text"], 4 | }; 5 | -------------------------------------------------------------------------------- /terraform/20-app/ecs.tf: -------------------------------------------------------------------------------- 1 | module "ecs" { 2 | source = "terraform-aws-modules/ecs/aws" 3 | version = "5.11.4" 4 | 5 | cluster_name = "${local.prefix}-cluster" 6 | } 7 | -------------------------------------------------------------------------------- /terraform/20-app/etc/prod.tfvars: -------------------------------------------------------------------------------- 1 | environment_type = "prod" 2 | halo_account_type = "lv1" 3 | one_nat_gateway_per_az = true 4 | single_nat_gateway = false 5 | -------------------------------------------------------------------------------- /terraform/20-app/etc/uat.tfvars: -------------------------------------------------------------------------------- 1 | environment_type = "uat" 2 | one_nat_gateway_per_az = true 3 | single_nat_gateway = false 4 | halo_account_type = "nl3" 5 | -------------------------------------------------------------------------------- /terraform/20-app/s3.vpc-flow-logs.tf: -------------------------------------------------------------------------------- 1 | data "aws_s3_bucket" "vpc_flow_logs_eu_west_2" { 2 | bucket = "uhd-aws-vpc-flow-logs-${local.account_id}-${local.region}" 3 | } 4 | -------------------------------------------------------------------------------- /src/lambda-db-password-rotation/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reporters: ["default", "jest-junit"], 3 | coverageReporters: ["json-summary", "text"], 4 | }; 5 | -------------------------------------------------------------------------------- /src/public-api-cloud-front-viewer-request/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reporters: ["default", "jest-junit"], 3 | coverageReporters: ["json-summary", "text"], 4 | }; 5 | -------------------------------------------------------------------------------- /src/legacy-dashboard-redirect-viewer-request/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reporters: ["default", "jest-junit"], 3 | coverageReporters: ["json-summary", "text"], 4 | }; 5 | -------------------------------------------------------------------------------- /terraform/10-account/etc/prod.tfvars: -------------------------------------------------------------------------------- 1 | account_dns_name = "ukhsa-dashboard.data.gov.uk" 2 | legacy_account_dns_name = "coronavirus.data.gov.uk" 3 | halo_account_type = "lv1" 4 | -------------------------------------------------------------------------------- /terraform/20-app/s3.cloud-front-logs.tf: -------------------------------------------------------------------------------- 1 | data "aws_s3_bucket" "cloud_front_logs_eu_west_2" { 2 | bucket = "uhd-aws-cloud-front-access-logs-${local.account_id}-${local.region}" 3 | } 4 | -------------------------------------------------------------------------------- /terraform/10-account/etc/dev.tfvars: -------------------------------------------------------------------------------- 1 | account_dns_name = "dev.ukhsa-dashboard.data.gov.uk" 2 | legacy_account_dns_name = "dev.coronavirus.data.gov.uk" 3 | halo_account_type = "nl1" 4 | -------------------------------------------------------------------------------- /terraform/10-account/etc/test.tfvars: -------------------------------------------------------------------------------- 1 | account_dns_name = "test.ukhsa-dashboard.data.gov.uk" 2 | legacy_account_dns_name = "test.coronavirus.data.gov.uk" 3 | halo_account_type = "nl2" 4 | -------------------------------------------------------------------------------- /terraform/10-account/etc/uat.tfvars: -------------------------------------------------------------------------------- 1 | account_dns_name = "uat.ukhsa-dashboard.data.gov.uk" 2 | legacy_account_dns_name = "uat.coronavirus.data.gov.uk" 3 | halo_account_type = "nl3" 4 | -------------------------------------------------------------------------------- /terraform/10-account/secrets-manager.tf: -------------------------------------------------------------------------------- 1 | resource "aws_secretsmanager_secret" "sentinel_external_id" { 2 | name = "sentinel/external-id" 3 | kms_key_id = module.kms_secrets.key_id 4 | } 5 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/cloudfront.kv-store.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudfront_key_value_store" "password_protection" { 2 | count = var.create ? 1 : 0 3 | name = var.name 4 | } 5 | -------------------------------------------------------------------------------- /terraform/20-app/kinesis-data-stream.tf: -------------------------------------------------------------------------------- 1 | resource "aws_kinesis_stream" "kinesis_data_stream_ingestion" { 2 | name = "${local.prefix}-ingestion" 3 | stream_mode_details { 4 | stream_mode = "ON_DEMAND" 5 | } 6 | } -------------------------------------------------------------------------------- /terraform/modules/ecr/data.aws_ecr_image.tf: -------------------------------------------------------------------------------- 1 | data "aws_ecr_image" "this" { 2 | depends_on = [terraform_data.initial_image_provisioner] 3 | repository_name = module.ecr.repository_name 4 | most_recent = true 5 | } 6 | -------------------------------------------------------------------------------- /terraform/10-account/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "5.78.0" 6 | } 7 | } 8 | required_version = ">= 1.4.5" 9 | } 10 | 11 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/outputs.tf: -------------------------------------------------------------------------------- 1 | output "arn" { 2 | description = "The ARN of the Cloudfront function" 3 | value = var.create ? aws_cloudfront_function.password_protection[0].arn : "" 4 | } 5 | -------------------------------------------------------------------------------- /terraform/20-app/cloudwatch.rum.front-end.tf: -------------------------------------------------------------------------------- 1 | module "cloudwatch_rum_front_end" { 2 | source = "../modules/cloudwatch-rum" 3 | name = "${local.prefix}-front-end-app-monitor" 4 | domain_name = local.dns_names.front_end 5 | } 6 | -------------------------------------------------------------------------------- /terraform/modules/cloudwatch-rum/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 5.0.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/modules/cloud-watch-canary/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 5.58.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/10-account/s3.account-settings.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_account_public_access_block" "account" { 2 | block_public_acls = true 3 | block_public_policy = true 4 | ignore_public_acls = true 5 | restrict_public_buckets = true 6 | } 7 | -------------------------------------------------------------------------------- /.github/actions/setup-zsh/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup zsh" 2 | 3 | description: "Setup zsh" 4 | 5 | runs: 6 | using: "composite" 7 | steps: 8 | - run: | 9 | sudo apt-get update 10 | sudo apt-get install -y zsh 11 | shell: bash 12 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 5.58.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_region" "current" {} 2 | 3 | data "aws_caller_identity" "current" {} 4 | 5 | locals { 6 | account_id = data.aws_caller_identity.current.account_id 7 | region = data.aws_region.current.name 8 | } 9 | -------------------------------------------------------------------------------- /terraform/10-account/s3.athena-query-results.tf: -------------------------------------------------------------------------------- 1 | module "s3_athena_query_results" { 2 | source = "terraform-aws-modules/s3-bucket/aws" 3 | version = "4.1.2" 4 | 5 | bucket = "athena-query-results-${local.account_id}" 6 | 7 | attach_deny_insecure_transport_policy = true 8 | } 9 | -------------------------------------------------------------------------------- /.github/actions/short-sha/action.yml: -------------------------------------------------------------------------------- 1 | name: "Short SHA" 2 | 3 | description: "Add SHORT_SHA env property with commit short sha" 4 | 5 | runs: 6 | using: "composite" 7 | steps: 8 | - run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV 9 | shell: bash 10 | -------------------------------------------------------------------------------- /terraform/10-account/route-53.wke-uat-account.tf: -------------------------------------------------------------------------------- 1 | module "route_53_zone_wke_uat_account" { 2 | source = "terraform-aws-modules/route53/aws//modules/zones" 3 | version = "3.1.0" 4 | 5 | create = local.account == "uat" 6 | 7 | zones = { 8 | (local.wke_dns_names.train) = {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /terraform/10-account/route-53.account.tf: -------------------------------------------------------------------------------- 1 | module "route_53_zone_account" { 2 | source = "terraform-aws-modules/route53/aws//modules/zones" 3 | version = "3.1.0" 4 | 5 | create = true 6 | 7 | zones = { 8 | (var.account_dns_name) = {} 9 | (var.legacy_account_dns_name) = {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/20-app/sns.alarms.tf: -------------------------------------------------------------------------------- 1 | module "sns_topic_alarms" { 2 | source = "terraform-aws-modules/sns/aws" 3 | name = "${local.prefix}-alarms" 4 | 5 | subscriptions = { 6 | lambda = { 7 | protocol = "lambda" 8 | endpoint = module.lambda_alarm_notification.lambda_function_arn 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /terraform/10-account/route-53.wke-test-account.tf: -------------------------------------------------------------------------------- 1 | module "route_53_zone_wke_test_account" { 2 | source = "terraform-aws-modules/route53/aws//modules/zones" 3 | version = "3.1.0" 4 | 5 | create = local.account == "test" 6 | 7 | zones = { 8 | (local.wke_dns_names.pen) = {} 9 | (local.wke_dns_names.perf) = {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/modules/ecr/outputs.tf: -------------------------------------------------------------------------------- 1 | output "image_uri" { 2 | value = data.aws_ecr_image.this.image_uri 3 | } 4 | 5 | output "repo_name" { 6 | value = module.ecr.repository_name 7 | } 8 | 9 | output "repo_url" { 10 | value = module.ecr.repository_url 11 | } 12 | 13 | output "repo_arn" { 14 | value = module.ecr.repository_arn 15 | } 16 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/vars.tf: -------------------------------------------------------------------------------- 1 | variable "create" { 2 | description = "Whether to create the Cloudfront function and KV store" 3 | type = bool 4 | default = false 5 | } 6 | 7 | variable "name" { 8 | description = "The name to associate with the Cloudfront components" 9 | type = string 10 | } 11 | -------------------------------------------------------------------------------- /terraform/modules/cloudwatch-rum/cloudwatch.rum.tf: -------------------------------------------------------------------------------- 1 | resource "aws_rum_app_monitor" "this" { 2 | name = var.name 3 | domain = var.domain_name 4 | 5 | app_monitor_configuration { 6 | session_sample_rate = var.session_sample_rate 7 | 8 | identity_pool_id = aws_cognito_identity_pool.this.id 9 | guest_role_arn = aws_iam_role.this.arn 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/20-app/s3.waf-logs.tf: -------------------------------------------------------------------------------- 1 | data "aws_s3_bucket" "halo_waf_logs_eu_west_2" { 2 | bucket = "aws-waf-logs-halo-${local.account_id}-eu-west-2-wpr-${var.halo_account_type}-avm-bs-gr-r" 3 | } 4 | 5 | data "aws_s3_bucket" "halo_waf_logs_us_east_1" { 6 | bucket = "aws-waf-logs-halo-${local.account_id}-us-east-1-wpr-${var.halo_account_type}-avm-bs-gr-r" 7 | provider = aws.us_east_1 8 | } 9 | -------------------------------------------------------------------------------- /terraform/modules/cloud-watch-canary/script.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | file_content = file(var.script_path) 3 | zip = "builds/${var.name}-${sha256(local.file_content)}.zip" 4 | } 5 | 6 | data "archive_file" "canary_script" { 7 | type = "zip" 8 | output_path = local.zip 9 | source { 10 | content = local.file_content 11 | filename = "nodejs/node_modules/index.js" 12 | } 13 | } -------------------------------------------------------------------------------- /scripts/_update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function _update() { 4 | local workspace="$(_get_workspace_name)" 5 | 6 | uhd terraform init 7 | uhd terraform apply 8 | uhd docker update dev $workspace 9 | 10 | export AWS_PROFILE=uhd-dev/assumed-role 11 | 12 | uhd ecs restart-services 13 | uhd lambda restart-functions 14 | 15 | export AWS_PROFILE=uhd-tools/assumed-role 16 | } -------------------------------------------------------------------------------- /terraform/10-account/acm.account.tf: -------------------------------------------------------------------------------- 1 | module "acm_account" { 2 | source = "terraform-aws-modules/acm/aws" 3 | version = "~> 5.0" 4 | 5 | domain_name = var.account_dns_name 6 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.account_dns_name] 7 | 8 | subject_alternative_names = [ 9 | "*.${var.account_dns_name}" 10 | ] 11 | 12 | wait_for_validation = true 13 | } 14 | -------------------------------------------------------------------------------- /terraform/20-app/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "5.78.0" 6 | } 7 | random = { 8 | source = "hashicorp/random" 9 | version = "3.6.3" 10 | } 11 | local = { 12 | source = "hashicorp/local" 13 | version = "2.5.2" 14 | } 15 | } 16 | required_version = ">= 1.4.5" 17 | } 18 | 19 | -------------------------------------------------------------------------------- /terraform/20-app/waf.ip-allow-set.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_ip_set" "ip_allow_list" { 2 | name = "${local.prefix}-ip-allow-list" 3 | scope = "CLOUDFRONT" 4 | provider = aws.us_east_1 5 | ip_address_version = "IPV4" 6 | addresses = concat( 7 | local.complete_ip_allow_list, 8 | formatlist("%s/32", module.vpc.nat_public_ips) 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /terraform/modules/cloud-watch-canary/security-group.tf: -------------------------------------------------------------------------------- 1 | module "canary_security_group" { 2 | source = "terraform-aws-modules/security-group/aws" 3 | version = "5.1.0" 4 | 5 | name = var.name 6 | vpc_id = var.vpc_id 7 | 8 | egress_with_cidr_blocks = [ 9 | { 10 | description = "https to internet" 11 | rule = "https-443-tcp" 12 | cidr_blocks = "0.0.0.0/0" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /terraform/modules/cloudwatch-rum/cognito.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cognito_identity_pool" "this" { 2 | identity_pool_name = var.name 3 | allow_unauthenticated_identities = true 4 | allow_classic_flow = true 5 | } 6 | 7 | resource "aws_cognito_identity_pool_roles_attachment" "this" { 8 | identity_pool_id = aws_cognito_identity_pool.this.id 9 | roles = { 10 | "unauthenticated" = aws_iam_role.this.arn 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /terraform/20-app/state.account-layer.tf: -------------------------------------------------------------------------------- 1 | data "terraform_remote_state" "account" { 2 | backend = "s3" 3 | 4 | workspace = var.environment_type 5 | 6 | config = { 7 | region = "eu-west-2" 8 | bucket = "uhd-terraform-states" 9 | dynamodb_table = "terraform-state-lock" 10 | key = "account/state.tfstate" 11 | } 12 | } 13 | 14 | locals { 15 | account_layer = data.terraform_remote_state.account.outputs 16 | } 17 | -------------------------------------------------------------------------------- /terraform/10-account/vars.tf: -------------------------------------------------------------------------------- 1 | variable "account_dns_name" {} 2 | variable "assume_account_id" { 3 | sensitive = true 4 | } 5 | 6 | variable "assume_role_name" { 7 | default = "TerraformOperator" 8 | } 9 | 10 | variable "legacy_account_dns_name" {} 11 | 12 | variable "python_version" {} 13 | 14 | variable "tools_account_id" { 15 | sensitive = true 16 | } 17 | 18 | variable "etl_account_id" { 19 | sensitive = true 20 | } 21 | 22 | variable "halo_account_type" {} -------------------------------------------------------------------------------- /terraform/modules/ecr/local.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | standard_ecr_lifecycle_policy = jsonencode({ 3 | rules = [ 4 | { 5 | rulePriority = 1, 6 | description = "Keep the last 30 images", 7 | selection = { 8 | tagStatus = "any", 9 | countType = "imageCountMoreThan", 10 | countNumber = 30 11 | }, 12 | action = { 13 | type = "expire" 14 | } 15 | } 16 | ] 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /terraform/10-account/sqs.sentinel-alb-access-logs.tf: -------------------------------------------------------------------------------- 1 | module "sqs_sentinel_alb_access_logs" { 2 | source = "terraform-aws-modules/sqs/aws" 3 | 4 | name = "uhd-sentinel-alb-access-logs" 5 | 6 | queue_policy_statements = { 7 | s3 = { 8 | actions = [ 9 | "sqs:SendMessage", 10 | ] 11 | principals = [ 12 | { 13 | type = "Service" 14 | identifiers = ["s3.amazonaws.com"] 15 | } 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/actions/setup-terraform/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Terraform" 2 | 3 | description: "Setup Terraform using tfenv" 4 | 5 | runs: 6 | using: "composite" 7 | steps: 8 | - id: install-tfenv 9 | run: | 10 | git clone https://github.com/tfutils/tfenv.git ~/.tfenv 11 | echo $HOME/.tfenv/bin >> $GITHUB_PATH 12 | shell: bash 13 | 14 | - id: install-terraform 15 | run: | 16 | tfenv install 17 | terraform --version 18 | shell: bash 19 | -------------------------------------------------------------------------------- /terraform/10-account/iam.terraform-role.tf: -------------------------------------------------------------------------------- 1 | module "iam_terraform_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.44.0" 4 | create_role = true 5 | 6 | role_name = "TerraformOperator" 7 | role_requires_mfa = false 8 | 9 | custom_role_policy_arns = [ 10 | "arn:aws:iam::aws:policy/AdministratorAccess", 11 | ] 12 | 13 | trusted_role_arns = [ 14 | "arn:aws:iam::${var.tools_account_id}:root", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=publichealthengland_dashboard-framework-infra 2 | sonar.organization=publichealthengland 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=data-dashboard-infra 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /src/public-api-cloud-front-viewer-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "public-api-cloud-front-viewer-request", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest --watch", 8 | "test:ci": "jest --ci --coverage" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "babel-jest": "^29.7.0", 14 | "babel-plugin-rewire": "^1.2.0", 15 | "jest": "^29.7.0", 16 | "jest-junit": "^16.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /terraform/modules/cloudwatch-rum/outputs.tf: -------------------------------------------------------------------------------- 1 | output "rum_application_id" { 2 | description = "The `APPLICATION_ID` required for the JS snippet. Returns an empty string if `create` var is False." 3 | value = var.create ? aws_rum_app_monitor.this.app_monitor_id : "" 4 | } 5 | 6 | output "rum_cognito_pool_id" { 7 | description = "The `identityPoolId` required for the JS snippet. Returns an empty string if `create` var is False." 8 | value = var.create ? aws_cognito_identity_pool.this.id : "" 9 | } 10 | -------------------------------------------------------------------------------- /terraform/10-account/acm.wke-uat-account.tf: -------------------------------------------------------------------------------- 1 | module "acm_wke_train" { 2 | source = "terraform-aws-modules/acm/aws" 3 | version = "5.0.1" 4 | 5 | create_certificate = local.account == "uat" 6 | 7 | domain_name = local.wke_dns_names.train 8 | zone_id = local.account == "uat" ? module.route_53_zone_wke_uat_account.route53_zone_zone_id[local.wke_dns_names.train] : "" 9 | 10 | subject_alternative_names = [ 11 | "*.${local.wke_dns_names.train}" 12 | ] 13 | 14 | wait_for_validation = true 15 | } 16 | -------------------------------------------------------------------------------- /terraform/10-account/iam.shield-advanced-role.tf: -------------------------------------------------------------------------------- 1 | module "shield_advanced_drt_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.44.0" 4 | 5 | create_role = true 6 | max_session_duration = 43200 7 | role_name = "ShieldAdvancedDRTRole" 8 | role_requires_mfa = false 9 | 10 | custom_role_policy_arns = ["arn:aws:iam::aws:policy/service-role/AWSShieldDRTAccessPolicy"] 11 | trusted_role_services = ["drt.shield.amazonaws.com"] 12 | } 13 | -------------------------------------------------------------------------------- /cloud-watch/log-insights/public-api.viewer-request.headers.query: -------------------------------------------------------------------------------- 1 | # 2 | # Use to query the cloud watch log group for the public api cloud front viewer request function 3 | # It will extract uri, accept header, and the value we normalized it to from the logs 4 | # 5 | 6 | parse @message 'uri: "*"' as uri 7 | | parse 'accept_header: "*"' as accept_header 8 | | parse 'normalized_accept_header: "*"' as normalized_accept_header 9 | | filter not (@message like /.*\Q START DistributionID: \E.*/ or @message like /.*\Q END\E/) 10 | -------------------------------------------------------------------------------- /terraform/10-account/kms.tf: -------------------------------------------------------------------------------- 1 | module "kms_secrets" { 2 | source = "terraform-aws-modules/kms/aws" 3 | version = "3.1.0" 4 | 5 | description = "Account level secrets encryption key" 6 | key_usage = "ENCRYPT_DECRYPT" 7 | deletion_window_in_days = 7 8 | multi_region = false 9 | 10 | key_owners = ["arn:aws:iam::${var.tools_account_id}:root"] 11 | 12 | aliases = ["${local.project}-${local.account}-account-secrets"] 13 | aliases_use_name_prefix = true 14 | } 15 | -------------------------------------------------------------------------------- /terraform/20-app/shield-advanced.tf: -------------------------------------------------------------------------------- 1 | resource "aws_shield_application_layer_automatic_response" "cloudfront_frontend" { 2 | count = local.is_prod ? 1 : 0 3 | resource_arn = module.cloudfront_front_end.cloudfront_distribution_arn 4 | action = "BLOCK" 5 | } 6 | 7 | resource "aws_shield_application_layer_automatic_response" "cloudfront_public_api" { 8 | count = local.is_prod ? 1 : 0 9 | resource_arn = module.cloudfront_public_api.cloudfront_distribution_arn 10 | action = "BLOCK" 11 | } 12 | -------------------------------------------------------------------------------- /terraform/modules/ecr/provisioner.initial-image.tf: -------------------------------------------------------------------------------- 1 | resource "terraform_data" "initial_image_provisioner" { 2 | depends_on = [module.ecr] 3 | provisioner "local-exec" { 4 | command = <> $GITHUB_ENV 16 | echo "TARGET_ACCOUNT_NAME=$(_get_target_aws_account_name 20-app $ENVIRONMENT_NAME)" >> $GITHUB_ENV 17 | echo "IS_ACCOUNT_LAYER_BRANCH=$is_account_layer_branch" >> $GITHUB_ENV 18 | env: 19 | branch: ${{ inputs.branch }} 20 | is_account_layer_branch: ${{ contains(fromJSON('["env/dev/dev", "env/test/test", "env/uat/uat"]'), inputs.branch) }} 21 | shell: bash 22 | -------------------------------------------------------------------------------- /terraform/20-app/ecs-jobs/bootstrap-env.tftpl: -------------------------------------------------------------------------------- 1 | { 2 | "cluster": "${cluster_arn}", 3 | "count": 1, 4 | "launchType": "FARGATE", 5 | "networkConfiguration": { 6 | "awsvpcConfiguration": { 7 | "subnets": ${jsonencode([for subnet_id in subnet_ids : "${subnet_id}"])}, 8 | "securityGroups": [ 9 | "${security_group_id}" 10 | ] 11 | } 12 | }, 13 | "overrides": { 14 | "containerOverrides": [ 15 | { 16 | "name": "api", 17 | "command": [ 18 | "scripts/boot.sh", 19 | "${cms_admin_user_password}" 20 | ], 21 | "environment": [ 22 | { 23 | "name": "POSTGRES_HOST", 24 | "value": "${aurora_writer_endpoint}" 25 | }, 26 | { 27 | "name": "FRONTEND_URL", 28 | "value": "${frontend_url}" 29 | } 30 | ] 31 | } 32 | ] 33 | }, 34 | "taskDefinition": "${task_arn}" 35 | } 36 | -------------------------------------------------------------------------------- /terraform/10-account/iam.sso-roles.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_roles" "administrator" { 2 | name_regex = "AWSReservedSSO_Administrator_*" 3 | path_prefix = "/aws-reserved/sso.amazonaws.com/" 4 | } 5 | 6 | data "aws_iam_roles" "developer" { 7 | name_regex = "AWSReservedSSO_Developer_*" 8 | path_prefix = "/aws-reserved/sso.amazonaws.com/" 9 | } 10 | 11 | data "aws_iam_roles" "operations" { 12 | name_regex = "AWSReservedSSO_Operations_*" 13 | path_prefix = "/aws-reserved/sso.amazonaws.com/" 14 | } 15 | 16 | data "aws_iam_roles" "report_viewer" { 17 | name_regex = "AWSReservedSSO_ReportViewer_*" 18 | path_prefix = "/aws-reserved/sso.amazonaws.com/" 19 | } 20 | 21 | locals { 22 | sso_role_arns = { 23 | administrator = one(data.aws_iam_roles.administrator.arns) 24 | developer = one(data.aws_iam_roles.developer.arns) 25 | operations = one(data.aws_iam_roles.operations.arns) 26 | report_viewer = one(data.aws_iam_roles.report_viewer.arns) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/fast-forward-env-branches.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | env_branches=("env/uat/uat" 4 | "env/uat/staging" 5 | "env/test/test" 6 | "env/test/perf" 7 | "env/dev/dpd" 8 | "env/dev/dev") 9 | 10 | for branch in ${env_branches[@]}; do 11 | env=$(echo $branch | cut -d : -f 1 | xargs basename) 12 | 13 | if git merge-base --is-ancestor origin/$branch main 14 | then 15 | echo "Fast forward merge is possible to branch $branch" 16 | echo 17 | 18 | git checkout $branch 19 | git merge main --ff-only 20 | git push 21 | echo 22 | 23 | if [ $CI ]; then 24 | echo "deploy_$env=true" >> "$GITHUB_OUTPUT" 25 | fi 26 | else 27 | echo "Fast forward merge is not possible to branch $branch" 28 | echo 29 | 30 | if [ $CI ]; then 31 | echo "deploy_$env=false" >> "$GITHUB_OUTPUT" 32 | fi 33 | fi 34 | done 35 | -------------------------------------------------------------------------------- /.github/actions/npm-test/action.yml: -------------------------------------------------------------------------------- 1 | name: "NPM Test" 2 | 3 | description: "Runs tests in a node project" 4 | 5 | inputs: 6 | function-name: 7 | required: true 8 | type: string 9 | 10 | runs: 11 | using: "composite" 12 | 13 | steps: 14 | - name: NPM Install 15 | run: npm ci --no-audit --no-fund 16 | shell: bash 17 | working-directory: ./src/${{inputs.function-name}} 18 | 19 | - name: Unit tests 20 | run: npm run test:ci 21 | shell: bash 22 | working-directory: ./src/${{inputs.function-name}} 23 | 24 | - name: Cache unit test summary 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: ${{inputs.function-name}}-coverage-summary 28 | path: ./src/${{inputs.function-name}}/coverage/coverage-summary.json 29 | 30 | - name: Cache unit test report 31 | uses: actions/upload-artifact@v3 32 | with: 33 | name: ${{inputs.function-name}}-coverage-report 34 | path: ./src/${{inputs.function-name}}/junit.xml 35 | -------------------------------------------------------------------------------- /terraform/10-account/acm.cloud-front.tf: -------------------------------------------------------------------------------- 1 | module "acm_cloud_front" { 2 | source = "terraform-aws-modules/acm/aws" 3 | version = "5.0.1" 4 | 5 | domain_name = var.account_dns_name 6 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.account_dns_name] 7 | 8 | providers = { 9 | aws = aws.us_east_1 10 | } 11 | 12 | subject_alternative_names = [ 13 | "*.${var.account_dns_name}" 14 | ] 15 | 16 | validation_method = "DNS" 17 | wait_for_validation = true 18 | } 19 | 20 | module "acm_cloud_front_legacy_dashboard" { 21 | source = "terraform-aws-modules/acm/aws" 22 | version = "5.0.1" 23 | 24 | domain_name = var.legacy_account_dns_name 25 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.legacy_account_dns_name] 26 | 27 | providers = { 28 | aws = aws.us_east_1 29 | } 30 | 31 | subject_alternative_names = [ 32 | "*.${var.legacy_account_dns_name}" 33 | ] 34 | 35 | validation_method = "DNS" 36 | wait_for_validation = true 37 | } 38 | -------------------------------------------------------------------------------- /terraform/10-account/acm.wke-test-account.tf: -------------------------------------------------------------------------------- 1 | module "acm_wke_pen" { 2 | source = "terraform-aws-modules/acm/aws" 3 | version = "5.0.1" 4 | 5 | create_certificate = local.account == "test" 6 | 7 | domain_name = local.wke_dns_names.pen 8 | zone_id = local.account == "test" ? module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.pen] : "" 9 | 10 | subject_alternative_names = [ 11 | "*.${local.wke_dns_names.pen}" 12 | ] 13 | 14 | validation_method = "DNS" 15 | wait_for_validation = true 16 | } 17 | 18 | module "acm_wke_perf" { 19 | source = "terraform-aws-modules/acm/aws" 20 | version = "5.0.1" 21 | 22 | create_certificate = local.account == "test" 23 | 24 | domain_name = local.wke_dns_names.perf 25 | zone_id = local.account == "test" ? module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.perf] : "" 26 | 27 | subject_alternative_names = [ 28 | "*.${local.wke_dns_names.perf}" 29 | ] 30 | 31 | validation_method = "DNS" 32 | wait_for_validation = true 33 | } 34 | -------------------------------------------------------------------------------- /terraform/10-account/s3.cloud-front-logs.tf: -------------------------------------------------------------------------------- 1 | module "s3_cloud_front_logs" { 2 | source = "terraform-aws-modules/s3-bucket/aws" 3 | version = "4.1.2" 4 | 5 | bucket = "uhd-aws-cloud-front-access-logs-${local.account_id}-${local.region}" 6 | 7 | attach_deny_insecure_transport_policy = true 8 | force_destroy = true 9 | control_object_ownership = true 10 | object_ownership = "BucketOwnerPreferred" 11 | 12 | grant = [ 13 | { 14 | type = "CanonicalUser" 15 | permission = "FULL_CONTROL" 16 | id = data.aws_canonical_user_id.current.id 17 | }, 18 | { 19 | type = "CanonicalUser" 20 | permission = "FULL_CONTROL" 21 | id = data.aws_cloudfront_log_delivery_canonical_user_id.cloudfront.id 22 | } 23 | ] 24 | 25 | owner = { 26 | id = data.aws_canonical_user_id.current.id 27 | } 28 | } 29 | 30 | data "aws_canonical_user_id" "current" {} 31 | 32 | data "aws_cloudfront_log_delivery_canonical_user_id" "cloudfront" {} 33 | -------------------------------------------------------------------------------- /.github/workflows/cleanup-ci-test-environments.yml: -------------------------------------------------------------------------------- 1 | name: Cleanup CI test environments 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * 1-5" 7 | 8 | env: 9 | AWS_REGION: "eu-west-2" 10 | 11 | permissions: 12 | id-token: write 13 | contents: read 14 | 15 | jobs: 16 | cleanup: 17 | name: Cleanup test environments 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ./.github/actions/setup-terraform 22 | - uses: ./.github/actions/setup-zsh 23 | 24 | - name: Configure AWS credentials for tools account 25 | uses: ./.github/actions/configure-aws-credentials 26 | with: 27 | aws-region: ${{ env.AWS_REGION }} 28 | tools-account-role: ${{ secrets.UHD_TERRAFORM_IAM_ROLE }} 29 | # Timeout after 6 hours 30 | role-duration-seconds: "21600" 31 | 32 | - name: Terraform cleanup 33 | run: | 34 | source uhd.sh 35 | uhd terraform init 36 | uhd terraform cleanup 37 | shell: zsh {0} 38 | -------------------------------------------------------------------------------- /scripts/_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function _data_help() { 4 | echo 5 | echo "uhd data [options]" 6 | echo 7 | echo "commands:" 8 | echo " help - this help screen" 9 | echo 10 | echo " upload [folder] - upload files to the ingest s3 bucket" 11 | echo 12 | 13 | return 0 14 | } 15 | 16 | function _data() { 17 | local verb=$1 18 | local args=(${@:2}) 19 | 20 | case $verb in 21 | "upload") _data_upload $args ;; 22 | 23 | *) _data_help ;; 24 | esac 25 | } 26 | 27 | function _data_upload() { 28 | local folder=$1 29 | 30 | if [[ -z ${folder} ]]; then 31 | echo "Folder is required" >&2 32 | return 1 33 | fi 34 | 35 | local bucket_id=$(_get_ingest_bucket_id) 36 | 37 | aws s3 cp $folder s3://$bucket_id/in/ --recursive 38 | } 39 | 40 | function _get_ingest_bucket_id() { 41 | local terraform_output_file=terraform/20-app/output.json 42 | 43 | local bucket_id=$(jq -r '.s3.value.ingest_bucket_id' $terraform_output_file) 44 | 45 | echo $bucket_id 46 | } -------------------------------------------------------------------------------- /terraform/20-app/eventbridge.tf: -------------------------------------------------------------------------------- 1 | module "eventbridge" { 2 | source = "terraform-aws-modules/eventbridge/aws" 3 | create_bus = false 4 | role_name = "${local.prefix}-eventbridge-role" 5 | 6 | rules = { 7 | "${local.prefix}-db-password-rotation" = { 8 | description = "Capture db password rotation events" 9 | event_pattern = jsonencode({ 10 | source : ["aws.secretsmanager"] 11 | detail : { 12 | eventSource : ["secretsmanager.amazonaws.com"], 13 | eventName : ["RotationSucceeded"] 14 | additionalEventData : { 15 | SecretId : [ 16 | local.main_db_aurora_password_secret_arn, 17 | local.feature_flags_db_aurora_password_secret_arn, 18 | ] 19 | } 20 | } 21 | }) 22 | } 23 | } 24 | 25 | targets = { 26 | "${local.prefix}-db-password-rotation" = [ 27 | { 28 | name = module.lambda_db_password_rotation.lambda_function_name 29 | arn = module.lambda_db_password_rotation.lambda_function_arn 30 | }, 31 | ] 32 | } 33 | } -------------------------------------------------------------------------------- /src/legacy-dashboard-redirect-viewer-request/index.js: -------------------------------------------------------------------------------- 1 | function handler(event) { 2 | const requestUrl = getRequestUrl(event.request); 3 | const redirectUrl = getRedirectUrl(event.request); 4 | 5 | console.log(`Redirecting ${requestUrl} to ${redirectUrl}`); 6 | 7 | const response = { 8 | statusCode: 301, 9 | statusDescription: "Moved Permanently", 10 | headers: { location: { value: redirectUrl } }, 11 | }; 12 | 13 | return response; 14 | } 15 | 16 | function getRedirectUrl(request) { 17 | const host = request.headers.host.value; 18 | const hostParts = host.split("."); 19 | 20 | if (hostParts.length == 5) { 21 | return `https://${hostParts[0]}.ukhsa-dashboard.data.gov.uk`; 22 | } 23 | 24 | if (hostParts.length == 6) { 25 | return `https://${hostParts[0]}.${hostParts[1]}.ukhsa-dashboard.data.gov.uk`; 26 | } 27 | 28 | return "https://ukhsa-dashboard.data.gov.uk"; 29 | } 30 | 31 | function getRequestUrl(request) { 32 | const host = request.headers.host.value; 33 | const uri = request.uri; 34 | 35 | return `${host}${uri}`; 36 | } 37 | 38 | handler; 39 | -------------------------------------------------------------------------------- /terraform/20-app/elasticache.tf: -------------------------------------------------------------------------------- 1 | module "app_elasticache_security_group" { 2 | source = "terraform-aws-modules/security-group/aws" 3 | version = "5.1.0" 4 | 5 | name = "${local.prefix}-app-elasticache" 6 | vpc_id = module.vpc.vpc_id 7 | 8 | ingress_with_source_security_group_id = [ 9 | { 10 | description = "private api tasks to cache" 11 | rule = "redis-tcp" 12 | source_security_group_id = module.ecs_service_private_api.security_group_id 13 | }, 14 | { 15 | description = "utility worker tasks to cache" 16 | rule = "redis-tcp" 17 | source_security_group_id = module.ecs_service_utility_worker.security_group_id 18 | } 19 | ] 20 | } 21 | 22 | resource "aws_elasticache_serverless_cache" "app_elasticache" { 23 | engine = "redis" 24 | name = "${local.prefix}-app-serverless-redis" 25 | 26 | major_engine_version = "7" 27 | snapshot_retention_limit = 1 28 | security_group_ids = [module.app_elasticache_security_group.security_group_id] 29 | subnet_ids = module.vpc.elasticache_subnets 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 UK Health Security Agency 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /terraform/modules/cloudwatch-rum/iam.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "this" { 2 | name = var.name 3 | assume_role_policy = data.aws_iam_policy_document.trust.json 4 | } 5 | 6 | data "aws_iam_policy_document" "trust" { 7 | statement { 8 | effect = "Allow" 9 | 10 | principals { 11 | type = "Federated" 12 | identifiers = ["cognito-identity.amazonaws.com"] 13 | } 14 | 15 | actions = ["sts:AssumeRoleWithWebIdentity"] 16 | 17 | condition { 18 | test = "StringEquals" 19 | variable = "cognito-identity.amazonaws.com:aud" 20 | values = [aws_cognito_identity_pool.this.id] 21 | } 22 | 23 | condition { 24 | test = "ForAnyValue:StringLike" 25 | variable = "cognito-identity.amazonaws.com:amr" 26 | values = ["unauthenticated"] 27 | } 28 | } 29 | } 30 | 31 | 32 | data "aws_iam_policy_document" "permission" { 33 | statement { 34 | effect = "Allow" 35 | actions = ["rum:PutRumEvents"] 36 | resources = [aws_rum_app_monitor.this.arn] 37 | } 38 | } 39 | 40 | resource "aws_iam_role_policy" "this" { 41 | name = var.name 42 | role = aws_iam_role.this.id 43 | policy = data.aws_iam_policy_document.permission.json 44 | } 45 | -------------------------------------------------------------------------------- /terraform/20-app/s3.archive-web-content.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | s3_archive_web_content_bucket_name = "${local.prefix}-archive-web-content" 3 | } 4 | 5 | module "s3_archive_web_content" { 6 | source = "terraform-aws-modules/s3-bucket/aws" 7 | version = "4.1.2" 8 | 9 | bucket = local.s3_archive_web_content_bucket_name 10 | 11 | attach_deny_insecure_transport_policy = true 12 | attach_policy = true 13 | force_destroy = true 14 | 15 | logging = { 16 | target_bucket = data.aws_s3_bucket.s3_access_logs.id 17 | target_prefix = "${local.s3_archive_web_content_bucket_name}/" 18 | } 19 | 20 | policy = jsonencode({ 21 | Version = "2012-10-17", 22 | Statement = [ 23 | { 24 | Effect = "Allow", 25 | Principal = { 26 | Service = "cloudfront.amazonaws.com" 27 | }, 28 | Action = [ 29 | "s3:GetObject", 30 | ], 31 | Resource = "arn:aws:s3:::${local.s3_archive_web_content_bucket_name}/*", 32 | Condition = { 33 | StringEquals = { 34 | "aws:SourceArn" : module.cloudfront_archive_web_content.cloudfront_distribution_arn 35 | } 36 | } 37 | } 38 | ] 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /terraform/20-app/vpc.tf: -------------------------------------------------------------------------------- 1 | module "vpc" { 2 | source = "terraform-aws-modules/vpc/aws" 3 | version = "5.12.1" 4 | 5 | name = "${local.prefix}-main" 6 | cidr = "10.0.0.0/16" 7 | 8 | azs = var.azs 9 | 10 | private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] # 256 IPs in each subnet 11 | public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] # 256 IPs in each subnet 12 | elasticache_subnets = ["10.0.200.0/26", "10.0.200.64/26", "10.0.200.128/26"] # 64 IPs in each subnet 13 | database_subnets = ["10.0.201.0/24", "10.0.202.0/24", "10.0.203.0/24"] # 256 IPs in each subnet 14 | 15 | create_database_subnet_route_table = local.enable_public_db 16 | create_database_internet_gateway_route = local.enable_public_db 17 | 18 | enable_dns_hostnames = true 19 | enable_dns_support = true 20 | 21 | enable_nat_gateway = true 22 | one_nat_gateway_per_az = var.one_nat_gateway_per_az 23 | single_nat_gateway = var.single_nat_gateway 24 | 25 | enable_flow_log = true 26 | flow_log_destination_arn = "${data.aws_s3_bucket.vpc_flow_logs_eu_west_2.arn}/${local.prefix}-main/" 27 | flow_log_destination_type = "s3" 28 | flow_log_file_format = "plain-text" 29 | } 30 | -------------------------------------------------------------------------------- /terraform/10-account/state.account-layer.tf: -------------------------------------------------------------------------------- 1 | data "terraform_remote_state" "dev_account" { 2 | backend = "s3" 3 | 4 | workspace = "dev" 5 | 6 | config = { 7 | region = "eu-west-2" 8 | bucket = "uhd-terraform-states" 9 | dynamodb_table = "terraform-state-lock" 10 | key = "account/state.tfstate" 11 | } 12 | } 13 | 14 | data "terraform_remote_state" "test_account" { 15 | backend = "s3" 16 | 17 | workspace = "test" 18 | 19 | config = { 20 | region = "eu-west-2" 21 | bucket = "uhd-terraform-states" 22 | dynamodb_table = "terraform-state-lock" 23 | key = "account/state.tfstate" 24 | } 25 | } 26 | 27 | data "terraform_remote_state" "uat_account" { 28 | backend = "s3" 29 | 30 | workspace = "uat" 31 | 32 | config = { 33 | region = "eu-west-2" 34 | bucket = "uhd-terraform-states" 35 | dynamodb_table = "terraform-state-lock" 36 | key = "account/state.tfstate" 37 | } 38 | } 39 | 40 | locals { 41 | account_states = { 42 | dev = data.terraform_remote_state.dev_account.outputs 43 | test = data.terraform_remote_state.test_account.outputs 44 | uat = data.terraform_remote_state.uat_account.outputs 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /terraform/modules/cloud-watch-canary/vars.tf: -------------------------------------------------------------------------------- 1 | variable "create" { 2 | description = "Whether to create the synthetic canary and its associated components" 3 | type = bool 4 | default = true 5 | } 6 | 7 | variable "name" { 8 | description = "The name to associate with the synthetic canary components" 9 | type = string 10 | } 11 | 12 | variable "vpc_id" { 13 | description = "The ID of the VPC to deploy the canary within" 14 | type = string 15 | } 16 | 17 | variable "s3_access_logs_id" { 18 | description = "The ID of the S3 bucket where access logs are sent to" 19 | type = string 20 | } 21 | 22 | variable "s3_logs_destination" { 23 | description = "Map containing the ID and the ARN of the S3 bucket where the results of the canary are to be sent." 24 | type = map(string) 25 | } 26 | 27 | variable "subnet_ids" { 28 | description = "The IDs of the subnets where this canary is to run." 29 | type = list(string) 30 | } 31 | 32 | variable "schedule_expression" { 33 | description = "The cron schedule expression to apply to the canary." 34 | type = string 35 | } 36 | 37 | variable "script_path" { 38 | description = "The file path of the script to attach to the canary" 39 | type = string 40 | } -------------------------------------------------------------------------------- /terraform/10-account/acm.cloud-front.wke-test-account.tf: -------------------------------------------------------------------------------- 1 | module "acm_cloud_front_wke_pen" { 2 | source = "terraform-aws-modules/acm/aws" 3 | version = "5.0.1" 4 | 5 | create_certificate = local.account == "test" 6 | 7 | domain_name = local.wke_dns_names.pen 8 | zone_id = local.account == "test" ? module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.pen] : "" 9 | 10 | providers = { 11 | aws = aws.us_east_1 12 | } 13 | 14 | subject_alternative_names = [ 15 | "*.${local.wke_dns_names.pen}" 16 | ] 17 | 18 | validation_method = "DNS" 19 | wait_for_validation = true 20 | } 21 | 22 | module "acm_cloud_front_wke_perf" { 23 | source = "terraform-aws-modules/acm/aws" 24 | version = "5.0.1" 25 | 26 | create_certificate = local.account == "test" 27 | 28 | domain_name = local.wke_dns_names.perf 29 | zone_id = local.account == "test" ? module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.perf] : "" 30 | 31 | providers = { 32 | aws = aws.us_east_1 33 | } 34 | 35 | subject_alternative_names = [ 36 | "*.${local.wke_dns_names.perf}" 37 | ] 38 | 39 | validation_method = "DNS" 40 | wait_for_validation = true 41 | } 42 | -------------------------------------------------------------------------------- /terraform/20-app/lambda.producer.tf: -------------------------------------------------------------------------------- 1 | module "lambda_producer" { 2 | source = "terraform-aws-modules/lambda/aws" 3 | version = "7.8.1" 4 | function_name = "${local.prefix}-producer" 5 | description = "Acts as the conduit between the S3 ingest bucket and the Kinesis data stream." 6 | 7 | cloudwatch_logs_retention_in_days = local.default_log_retention_in_days 8 | 9 | create_package = true 10 | runtime = "nodejs18.x" 11 | handler = "index.handler" 12 | source_path = "../../src/lambda-producer-handler" 13 | 14 | maximum_retry_attempts = 1 15 | timeout = 60 # Timeout after 1 minute 16 | 17 | architectures = ["arm64"] 18 | 19 | environment_variables = { 20 | KINESIS_DATA_STREAM_NAME = aws_kinesis_stream.kinesis_data_stream_ingestion.name 21 | } 22 | 23 | attach_policy_statements = true 24 | policy_statements = { 25 | get_items_from_in_folder_of_ingest_bucket = { 26 | actions = ["s3:GetObject"] 27 | effect = "Allow" 28 | resources = ["${module.s3_ingest.s3_bucket_arn}/in/*"] 29 | } 30 | write_to_kinesis = { 31 | effect = "Allow" 32 | actions = ["kinesis:PutRecord"] 33 | resources = [aws_kinesis_stream.kinesis_data_stream_ingestion.arn] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /terraform/20-app/route-53.legacy-dashboard-redirect.tf: -------------------------------------------------------------------------------- 1 | module "route_53_records_legacy_dashboard_redirect" { 2 | source = "terraform-aws-modules/route53/aws//modules/records" 3 | version = "3.1.0" 4 | 5 | create = !contains(["pen", "staging", "perf"], local.environment) 6 | 7 | zone_id = local.account_layer.dns.legacy.zone_id 8 | 9 | records = [ 10 | { 11 | name = local.environment 12 | type = "A" 13 | alias = { 14 | name = module.cloudfront_legacy_dashboard_redirect.cloudfront_distribution_domain_name 15 | zone_id = module.cloudfront_legacy_dashboard_redirect.cloudfront_distribution_hosted_zone_id 16 | } 17 | } 18 | ] 19 | } 20 | 21 | module "route_53_records_legacy_dashboard_redirect_wke_account" { 22 | source = "terraform-aws-modules/route53/aws//modules/records" 23 | version = "3.1.0" 24 | 25 | create = contains(local.wke.account, local.environment) && !contains(["pen", "staging", "perf"], local.environment) 26 | zone_id = local.account_layer.dns.legacy.zone_id 27 | 28 | records = [ 29 | { 30 | name = "" 31 | type = "A" 32 | alias = { 33 | name = module.cloudfront_legacy_dashboard_redirect.cloudfront_distribution_domain_name 34 | zone_id = module.cloudfront_legacy_dashboard_redirect.cloudfront_distribution_hosted_zone_id 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /terraform/20-app/cloudwatch.dashboards.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_dashboard" "data_ingestion_dashboard" { 2 | dashboard_name = "${local.prefix}-data-ingestion-dashboard" 3 | 4 | dashboard_body = jsonencode({ 5 | widgets = [ 6 | { 7 | type = "metric" 8 | x = 0 9 | y = 0 10 | width = 12 11 | height = 6 12 | properties = { 13 | metrics = [ 14 | ["AWS/Lambda", "Invocations", "FunctionName", module.lambda_ingestion.lambda_function_name], 15 | ["AWS/Lambda", "Errors", "FunctionName", module.lambda_ingestion.lambda_function_name], 16 | ] 17 | period = 60 18 | stat = "Average" 19 | region = local.region 20 | live_data = false 21 | title = "Invocations / errors" 22 | } 23 | }, 24 | { 25 | type = "metric" 26 | x = 12 27 | y = 0 28 | width = 12 29 | height = 6 30 | properties = { 31 | metrics = [ 32 | ["AWS/Lambda", "Duration", "FunctionName", module.lambda_ingestion.lambda_function_name], 33 | ] 34 | period = 60 35 | stat = "Average" 36 | region = local.region 37 | live_data = false 38 | title = "Ingestion lambda duration" 39 | } 40 | } 41 | ] 42 | }) 43 | } -------------------------------------------------------------------------------- /terraform/20-app/aurora-db.feature-flags.tf: -------------------------------------------------------------------------------- 1 | module "aurora_db_feature_flags" { 2 | source = "terraform-aws-modules/rds-aurora/aws" 3 | version = "9.5.0" 4 | 5 | name = "${local.prefix}-aurora-db-feature-flags" 6 | engine = "aurora-postgresql" 7 | engine_mode = "provisioned" 8 | engine_version = "15.5" 9 | storage_encrypted = true 10 | 11 | publicly_accessible = true 12 | 13 | manage_master_user_password = true 14 | database_name = "unleash" 15 | master_username = "unleash_user" 16 | 17 | monitoring_interval = 60 18 | apply_immediately = true 19 | skip_final_snapshot = true 20 | 21 | instance_class = "db.serverless" 22 | serverlessv2_scaling_configuration = { 23 | min_capacity = 1 24 | max_capacity = 10 25 | } 26 | instances = { 27 | 1 = {} 28 | } 29 | 30 | vpc_id = module.vpc.vpc_id 31 | db_subnet_group_name = module.vpc.database_subnet_group_name 32 | 33 | security_group_rules = { 34 | feature_flag_tasks_to_db = { 35 | type = "ingress" 36 | description = "feature flags tasks to feature flags db" 37 | protocol = "tcp" 38 | from_port = 4242 39 | to_port = 5432 40 | source_security_group_id = module.ecs_service_feature_flags.security_group_id 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /athena/tables/front-end-cloud-front-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-front-end-cloud-front-logs` ( 7 | `date` DATE, 8 | time STRING, 9 | location STRING, 10 | bytes BIGINT, 11 | request_ip STRING, 12 | method STRING, 13 | host STRING, 14 | uri STRING, 15 | status INT, 16 | referrer STRING, 17 | user_agent STRING, 18 | query_string STRING, 19 | cookie STRING, 20 | result_type STRING, 21 | request_id STRING, 22 | host_header STRING, 23 | request_protocol STRING, 24 | request_bytes BIGINT, 25 | time_taken FLOAT, 26 | xforwarded_for STRING, 27 | ssl_protocol STRING, 28 | ssl_cipher STRING, 29 | response_result_type STRING, 30 | http_version STRING, 31 | fle_status STRING, 32 | fle_encrypted_fields INT, 33 | c_port INT, 34 | time_to_first_byte FLOAT, 35 | x_edge_detailed_result_type STRING, 36 | sc_content_type STRING, 37 | sc_content_len BIGINT, 38 | sc_range_start BIGINT, 39 | sc_range_end BIGINT 40 | ) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LOCATION 's3://uhd-aws-cloud-front-access-logs-{ACCOUNT ID}-eu-west-2/uhd-{ENV NAME}-front-end/' TBLPROPERTIES ('skip.header.line.count' = '2') -------------------------------------------------------------------------------- /athena/tables/public-api-cloud-front-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-public-api-cloud-front-logs` ( 7 | `date` DATE, 8 | time STRING, 9 | location STRING, 10 | bytes BIGINT, 11 | request_ip STRING, 12 | method STRING, 13 | host STRING, 14 | uri STRING, 15 | status INT, 16 | referrer STRING, 17 | user_agent STRING, 18 | query_string STRING, 19 | cookie STRING, 20 | result_type STRING, 21 | request_id STRING, 22 | host_header STRING, 23 | request_protocol STRING, 24 | request_bytes BIGINT, 25 | time_taken FLOAT, 26 | xforwarded_for STRING, 27 | ssl_protocol STRING, 28 | ssl_cipher STRING, 29 | response_result_type STRING, 30 | http_version STRING, 31 | fle_status STRING, 32 | fle_encrypted_fields INT, 33 | c_port INT, 34 | time_to_first_byte FLOAT, 35 | x_edge_detailed_result_type STRING, 36 | sc_content_type STRING, 37 | sc_content_len BIGINT, 38 | sc_range_start BIGINT, 39 | sc_range_end BIGINT 40 | ) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LOCATION 's3://uhd-aws-cloud-front-access-logs-{ACCOUNT ID}-eu-west-2/uhd-{ENV NAME}-public-api/' TBLPROPERTIES ('skip.header.line.count' = '2') -------------------------------------------------------------------------------- /src/public-api-cloud-front-viewer-request/index.js: -------------------------------------------------------------------------------- 1 | function handler(event) { 2 | const request = event.request; 3 | 4 | const acceptHeader = request.headers.accept 5 | ? request.headers.accept.value 6 | : ""; 7 | 8 | const normalizedAcceptHeader = normalizeAcceptHeader(acceptHeader); 9 | const transformedAcceptHeader = getAcceptHeaderForRequestedJSONFormat(normalizedAcceptHeader, request) 10 | 11 | const message = `uri: "${request.uri}" accept_header: "${acceptHeader}" normalized_accept_header: "${normalizedAcceptHeader}"`; 12 | console.log(message); 13 | 14 | request.headers["accept"] = { value: transformedAcceptHeader }; 15 | 16 | return request; 17 | } 18 | 19 | function normalizeAcceptHeader(acceptHeader) { 20 | if (acceptHeader.includes("html")) { 21 | return "text/html"; 22 | } 23 | 24 | if (acceptHeader.includes("json") || acceptHeader == "") { 25 | return "application/json"; 26 | } 27 | 28 | return ""; 29 | } 30 | 31 | function getAcceptHeaderForRequestedJSONFormat(normalizedAcceptHeader, request) { 32 | // When a query param of `format=json` is provided 33 | // Then we return "application/json" as the accept header 34 | // Otherwise return the originally normalized accept header 35 | const formatQueryParam = request.querystring["format"] 36 | ? request.querystring["format"].value 37 | : ""; 38 | 39 | if (formatQueryParam === "json") { 40 | return "application/json"; 41 | } 42 | return normalizedAcceptHeader; 43 | } 44 | 45 | handler; 46 | -------------------------------------------------------------------------------- /terraform/20-app/api-keys.tf: -------------------------------------------------------------------------------- 1 | resource "random_password" "private_api_key_prefix" { 2 | length = 8 3 | min_numeric = 1 4 | min_lower = 1 5 | min_upper = 1 6 | special = false 7 | } 8 | 9 | resource "random_password" "private_api_key_suffix" { 10 | length = 32 11 | min_numeric = 1 12 | min_lower = 1 13 | min_upper = 1 14 | special = false 15 | } 16 | 17 | resource "random_password" "backend_cryptographic_signing_key" { 18 | length = 50 19 | min_lower = 1 20 | min_numeric = 1 21 | min_special = 1 22 | min_upper = 1 23 | special = true 24 | } 25 | 26 | resource "random_password" "feature_flags_client_api_key" { 27 | length = 56 28 | min_numeric = 1 29 | min_lower = 1 30 | special = false 31 | upper = false 32 | } 33 | 34 | resource "random_password" "feature_flags_x_auth" { 35 | length = 30 36 | min_numeric = 1 37 | min_lower = 1 38 | min_upper = 1 39 | special = false 40 | upper = false 41 | } 42 | 43 | resource "random_password" "feature_flags_admin_user_password" { 44 | length = 20 45 | min_numeric = 1 46 | min_lower = 1 47 | min_upper = 1 48 | min_special = 1 49 | special = true 50 | } 51 | 52 | locals { 53 | feature_flags_x_auth = random_password.feature_flags_x_auth.result 54 | feature_flags_client_api_key = "*:production.${random_password.feature_flags_client_api_key.result}" 55 | private_api_key = "${random_password.private_api_key_prefix.result}.${random_password.private_api_key_suffix.result}" 56 | } 57 | -------------------------------------------------------------------------------- /terraform/10-account/iam.sentinel-role.tf: -------------------------------------------------------------------------------- 1 | module "iam_sentinel_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.44.0" 4 | 5 | create_role = true 6 | max_session_duration = 3600 7 | role_name = "uhd-sentinel-role" 8 | role_requires_mfa = false 9 | role_sts_externalid = data.aws_secretsmanager_secret_version.sentinel_external_id.secret_string 10 | 11 | custom_role_policy_arns = [ 12 | module.iam_sentinel_policy.arn 13 | ] 14 | 15 | trusted_role_arns = [ 16 | "arn:aws:iam::197857026523:root", 17 | ] 18 | } 19 | 20 | module "iam_sentinel_policy" { 21 | source = "terraform-aws-modules/iam/aws//modules/iam-policy" 22 | version = "5.44.0" 23 | 24 | name = "uhd-sentinel-policy" 25 | 26 | policy = jsonencode( 27 | { 28 | Version = "2012-10-17", 29 | Statement = [ 30 | { 31 | Action = [ 32 | "SQS:ChangeMessageVisibility", 33 | "SQS:DeleteMessage", 34 | "SQS:ReceiveMessage", 35 | "SQS:GetQueueUrl" 36 | ], 37 | Effect = "Allow", 38 | Resource = module.sqs_sentinel_alb_access_logs.queue_arn 39 | }, 40 | { 41 | Action = [ 42 | "s3:GetObject" 43 | ], 44 | Effect = "Allow", 45 | Resource = "${module.s3_elb_logs.s3_bucket_arn}/*" 46 | } 47 | ] 48 | } 49 | ) 50 | } 51 | 52 | data "aws_secretsmanager_secret_version" "sentinel_external_id" { 53 | secret_id = aws_secretsmanager_secret.sentinel_external_id.id 54 | } 55 | -------------------------------------------------------------------------------- /terraform/20-app/cloudwatch.alarms.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_metric_alarm" cloudfront_frontend_500_errors { 2 | provider = aws.us_east_1 3 | count = local.needs_alarms ? 1 : 0 4 | 5 | alarm_name = "${local.prefix}-cloudfront-frontend-5xx-alarm" 6 | alarm_description = "HTTP 5xx errors in the frontend Cloudfront distribution." 7 | alarm_actions = [aws_sns_topic.cloudfront_alarms.arn] 8 | comparison_operator = "GreaterThanOrEqualToThreshold" 9 | evaluation_periods = 5 10 | threshold = 5 11 | period = 60 12 | dimensions = { 13 | DistributionId = module.cloudfront_front_end.cloudfront_distribution_id 14 | Region = "Global" 15 | } 16 | namespace = "AWS/CloudFront" 17 | metric_name = "5xxErrorRate" 18 | statistic = "Average" 19 | } 20 | 21 | resource "aws_cloudwatch_metric_alarm" cloudfront_frontend_400_errors { 22 | provider = aws.us_east_1 23 | count = local.needs_alarms ? 1 : 0 24 | 25 | alarm_name = "${local.prefix}-cloudfront-frontend-4xx-alarm" 26 | alarm_description = "HTTP 4xx errors in the frontend Cloudfront distribution." 27 | alarm_actions = [aws_sns_topic.cloudfront_alarms.arn] 28 | comparison_operator = "GreaterThanOrEqualToThreshold" 29 | evaluation_periods = 5 30 | threshold = 95 31 | period = 60 32 | dimensions = { 33 | DistributionId = module.cloudfront_front_end.cloudfront_distribution_id 34 | Region = "Global" 35 | } 36 | namespace = "AWS/CloudFront" 37 | metric_name = "4xxErrorRate" 38 | statistic = "Average" 39 | } 40 | -------------------------------------------------------------------------------- /terraform/modules/cloud-watch-canary/iam.tf: -------------------------------------------------------------------------------- 1 | module "iam_canary_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.40.0" 4 | 5 | create_role = true 6 | max_session_duration = 3600 7 | role_name = var.name 8 | role_requires_mfa = false 9 | 10 | custom_role_policy_arns = [module.iam_canary_policy.arn] 11 | trusted_role_services = ["lambda.amazonaws.com"] 12 | } 13 | 14 | module "iam_canary_policy" { 15 | source = "terraform-aws-modules/iam/aws//modules/iam-policy" 16 | version = "5.40.0" 17 | 18 | create_policy = true 19 | name = var.name 20 | 21 | policy = jsonencode( 22 | { 23 | Version = "2012-10-17", 24 | Statement = [ 25 | { 26 | Action = ["s3:PutObject"], 27 | Effect = "Allow", 28 | Resource = ["${var.s3_logs_destination.bucket_arn}/*"] 29 | }, 30 | { 31 | Action = ["s3:ListAllMyBuckets"], 32 | Effect = "Allow", 33 | Resource = ["*"] 34 | }, 35 | { 36 | Action = ["cloudwatch:PutMetricData"], 37 | Effect = "Allow", 38 | Resource = ["*"], 39 | Condition = { 40 | StringEquals = { 41 | "cloudwatch:namespace" = "CloudWatchSynthetics" 42 | } 43 | } 44 | }, 45 | { 46 | Action = [ 47 | "ec2:CreateNetworkInterface", 48 | "ec2:DescribeNetworkInterfaces", 49 | "ec2:DeleteNetworkInterface", 50 | ] 51 | Effect = "Allow", 52 | Resource = ["*"], 53 | } 54 | ] 55 | } 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /terraform/10-account/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "5.78.0" 6 | constraints = ">= 2.49.0, >= 3.74.0, >= 4.0.0, >= 4.36.0, >= 4.40.0, >= 5.27.0, >= 5.37.0, >= 5.49.0, 5.78.0" 7 | hashes = [ 8 | "h1:/EKXECKi3XzLR0SwaEyQucvsQx2TdyIF2DYabr9DVqM=", 9 | "h1:OUmta/bL/0S6g4K/Mn1LBkEnMWNCq4dLsfYrdrllcEo=", 10 | "h1:o7jz+dFixEcwjfdubken5ldmDJm1tkvM2adPtNDei3g=", 11 | "zh:0ae7d41b96441d0cf7ce2e1337657bdb2e1e5c9f1c2227b0642e1dcec2f9dfba", 12 | "zh:21f8f1edf477681ea3b095c02cad6b8e85262e45015de58e84e0c7b2bfe9a1f6", 13 | "zh:2bdc335e341bf98445255549ae93d66cfb9bca706e62b949da98fe467c182cad", 14 | "zh:2fe4096e260367a225a9faf4a424d62b87e5498f12cb43bdb6f4e713d11b82c3", 15 | "zh:3c63bb7a7925d65118d17461f4691a22dbb55ea39a7404e4d71f6ccca8765f8b", 16 | "zh:6609a28a1c638a1901d8007b5386868ccfd313b4df2e98b35d9fdef436974e3b", 17 | "zh:7ae3aef43bc4b365824cca4659cf92459d766800656e354bdbf83feabab835e8", 18 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 19 | "zh:c314efe454adc6ca483261c6906e64315aeb9db0c0332818714e9b81e07df0f0", 20 | "zh:cd3e30396b554bbc1d260252db8a0f344065d619038fe60ea870689cd32c6aa9", 21 | "zh:d1ba48fd9d8a1cb1daa927fb9e8bb708b857f2792d796e110460c6fdcd896a47", 22 | "zh:d31c8abe75cb9cdc1c59ad9d356a1c3ae1ba8cd29ac15eb7e01b6cd01221ab04", 23 | "zh:dc27c5c2116b4d9b404753f73bccaa635bce21f3bfb4bb7bc8e63225c36c98fe", 24 | "zh:de491f0d05408378413187475c815d8cb2ac6bfa63d0b42a30ad5ee492e51c07", 25 | "zh:eb44b45a40f80a309dd5b0eb7d7fcb2cbfe588fe2f18b173ef5851346898a662", 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /terraform/10-account/iam.green-ops-s3-replication-role.tf: -------------------------------------------------------------------------------- 1 | module "iam_green_ops_s3_replication_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.44.0" 4 | 5 | create_role = local.ship_cur_to_green_ops_dashboard 6 | max_session_duration = 3600 7 | role_name = "uhd-green-ops-s3-replication-role" 8 | role_requires_mfa = false 9 | 10 | custom_role_policy_arns = [ 11 | module.iam_green_ops_s3_replication_policy.arn 12 | ] 13 | 14 | trusted_role_arns = [ 15 | "arn:aws:iam::975276445027:root", 16 | ] 17 | 18 | trusted_role_services = [ 19 | "s3.amazonaws.com" 20 | ] 21 | } 22 | 23 | module "iam_green_ops_s3_replication_policy" { 24 | source = "terraform-aws-modules/iam/aws//modules/iam-policy" 25 | version = "5.44.0" 26 | 27 | create_policy = local.ship_cur_to_green_ops_dashboard 28 | name = "uhd-green-ops-s3-replication-policy" 29 | 30 | policy = jsonencode( 31 | { 32 | Version = "2012-10-17", 33 | Statement = [ 34 | { 35 | Action = [ 36 | "s3:ListBucket", 37 | "s3:GetReplicationConfiguration", 38 | "s3:GetObjectVersionForReplication", 39 | "s3:GetObjectVersionAcl", 40 | "s3:GetObjectVersionTagging", 41 | ], 42 | Effect = "Allow", 43 | Resource = [ 44 | module.s3_cur_exports.s3_bucket_arn, 45 | "${module.s3_cur_exports.s3_bucket_arn}/*", 46 | ] 47 | }, 48 | { 49 | Action = [ 50 | "s3:ReplicateObject", 51 | "s3:ReplicateDelete", 52 | "s3:ReplicateTags", 53 | ], 54 | Effect = "Allow", 55 | Resource = [ 56 | "${local.green_ops_bucket_arn}/*" 57 | ] 58 | } 59 | ] 60 | } 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /terraform/10-account/s3.vpc-flow-logs.tf: -------------------------------------------------------------------------------- 1 | module "s3_vpc_flow_logs" { 2 | source = "terraform-aws-modules/s3-bucket/aws" 3 | version = "4.1.2" 4 | 5 | bucket = "uhd-aws-vpc-flow-logs-${local.account_id}-${local.region}" 6 | 7 | attach_deny_insecure_transport_policy = true 8 | attach_policy = true 9 | force_destroy = true 10 | 11 | policy = jsonencode({ 12 | Version = "2012-10-17", 13 | Statement = [ 14 | { 15 | Sid = "AWSLogDeliveryWrite", 16 | Effect = "Allow", 17 | Principal = { 18 | Service= "delivery.logs.amazonaws.com" 19 | }, 20 | Action = "s3:PutObject", 21 | Resource = "arn:aws:s3:::uhd-aws-vpc-flow-logs-${local.account_id}-${local.region}/*", 22 | Condition = { 23 | StringEquals= { 24 | "aws:SourceAccount" = local.account_id, 25 | "s3:x-amz-acl" = "bucket-owner-full-control" 26 | }, 27 | ArnLike= { 28 | "aws:SourceArn"= "arn:aws:logs:region:${local.account_id}:*" 29 | } 30 | } 31 | }, 32 | { 33 | Sid= "AWSLogDeliveryAclCheck", 34 | Effect= "Allow", 35 | Principal= { 36 | Service= "delivery.logs.amazonaws.com" 37 | }, 38 | Action= [ 39 | "s3:GetBucketAcl", 40 | "s3:ListBucket" 41 | ], 42 | Resource= "arn:aws:s3:::uhd-aws-vpc-flow-logs-${local.account_id}-${local.region}", 43 | Condition= { 44 | StringEquals= { 45 | "aws:SourceAccount": local.account_id 46 | }, 47 | ArnLike= { 48 | "aws:SourceArn": "arn:aws:logs:region:${local.account_id}:*" 49 | } 50 | } 51 | } 52 | ] 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /terraform/modules/cloud-front-basic-password-protection/cloudfront.function.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudfront_function" "password_protection" { 2 | count = var.create ? 1 : 0 3 | 4 | name = var.name 5 | runtime = "cloudfront-js-2.0" 6 | publish = true 7 | key_value_store_associations = [ 8 | aws_cloudfront_key_value_store.password_protection[0].arn 9 | ] 10 | 11 | code = <<-EOF 12 | const cf = require('cloudfront') 13 | 14 | async function getCredentials(KvStoreId) { 15 | const kvsHandle = cf.kvs(KvStoreId); 16 | const username = await kvsHandle.get("username"); 17 | const password = await kvsHandle.get("password"); 18 | return { 19 | username: username, 20 | password: password, 21 | } 22 | } 23 | 24 | function extractFinalSection(inputString) { 25 | const parts = inputString.split('/'); 26 | return parts[parts.length - 1]; 27 | } 28 | 29 | function buildEncodedString(credentials) { 30 | const encodedCredentials = btoa(`$${credentials.username}:$${credentials.password}`); 31 | return `Basic $${encodedCredentials}` 32 | } 33 | 34 | async function handler(event) { 35 | const authorizationHeaders = event.request.headers.authorization; 36 | const kvStoreArn = "${aws_cloudfront_key_value_store.password_protection[0].arn}" 37 | const KvStoreId = extractFinalSection(kvStoreArn) 38 | 39 | const credentials = await getCredentials(KvStoreId) 40 | const encodedString = buildEncodedString(credentials) 41 | 42 | if (authorizationHeaders && authorizationHeaders.value === encodedString) { 43 | return event.request; 44 | } 45 | 46 | return { 47 | statusCode: 401, 48 | statusDescription: "Unauthorized", 49 | headers: { 50 | "www-authenticate": {value: 'Basic'}, 51 | }, 52 | }; 53 | } 54 | 55 | handler; 56 | EOF 57 | } 58 | -------------------------------------------------------------------------------- /terraform/20-app/lambda.db-password-rotation.tf: -------------------------------------------------------------------------------- 1 | module "lambda_db_password_rotation" { 2 | source = "terraform-aws-modules/lambda/aws" 3 | version = "7.8.1" 4 | function_name = "${local.prefix}-db-password-rotation" 5 | description = "Redeploys services which depend on the main database when the password in secrets manager is rotated" 6 | 7 | create_package = true 8 | runtime = "nodejs18.x" 9 | handler = "index.handler" 10 | source_path = "../../src/lambda-db-password-rotation" 11 | 12 | architectures = ["arm64"] 13 | maximum_retry_attempts = 1 14 | 15 | environment_variables = { 16 | ECS_CLUSTER_ARN = module.ecs.cluster_arn 17 | MAIN_DB_PASSWORD_SECRET_ARN = local.main_db_aurora_password_secret_arn 18 | FEATURE_FLAGS_DB_PASSWORD_SECRET_ARN = local.feature_flags_db_aurora_password_secret_arn 19 | CMS_ADMIN_ECS_SERVICE_NAME = module.ecs_service_cms_admin.name 20 | PRIVATE_API_ECS_SERVICE_NAME = module.ecs_service_private_api.name 21 | PUBLIC_API_ECS_SERVICE_NAME = module.ecs_service_public_api.name 22 | FEEDBACK_API_ECS_SERVICE_NAME = module.ecs_service_feedback_api.name 23 | FEATURE_FLAGS_ECS_SERVICE_NAME = module.ecs_service_feature_flags.name 24 | } 25 | 26 | attach_policy_statements = true 27 | policy_statements = { 28 | restart_ecs_services = { 29 | actions = ["ecs:UpdateService"] 30 | effect = "Allow" 31 | resources = [ 32 | module.ecs_service_private_api.id, 33 | module.ecs_service_public_api.id, 34 | module.ecs_service_cms_admin.id, 35 | module.ecs_service_feedback_api.id, 36 | module.ecs_service_feature_flags.id, 37 | 38 | ] 39 | } 40 | } 41 | 42 | create_current_version_allowed_triggers = false 43 | allowed_triggers = { 44 | allow_eventbridge_trigger = { 45 | principal = "events.amazonaws.com" 46 | source_arn = module.eventbridge.eventbridge_rule_arns["${local.prefix}-db-password-rotation"] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/_gh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function _gh_help() { 4 | echo 5 | echo "uhd gh [options]" 6 | echo 7 | echo "commands:" 8 | echo " help - this help screen" 9 | echo 10 | echo " clone - clone all the repos" 11 | echo " co [repo] [pr] - checkout the specified pull request" 12 | echo " main - switch to main branch in all repos and pull the latest if possible" 13 | echo 14 | 15 | return 0 16 | } 17 | 18 | function _gh() { 19 | local verb=$1 20 | local args=(${@:2}) 21 | 22 | case $verb in 23 | "clone") _gh_clone $args ;; 24 | "co") _gh_co $args ;; 25 | "main") _gh_main $args ;; 26 | 27 | *) _gh_help ;; 28 | esac 29 | } 30 | 31 | function _gh_clone() { 32 | local repos=($(_gh_get_repos)) 33 | 34 | for repo in ${repos[@]}; do 35 | echo "Cloning $repo..." 36 | git clone git@github.com:UKHSA-Internal/$repo.git $root/../$repo 37 | echo 38 | done 39 | } 40 | 41 | function _gh_main() { 42 | local repos=($(_gh_get_repos)) 43 | 44 | for repo in ${repos[@]}; do 45 | echo "Switching to main in $repo" 46 | cd $root/../$repo 47 | git checkout main 48 | echo 49 | echo "Pulling latest changes..." 50 | git pull --ff-only 51 | echo 52 | done 53 | 54 | cd $root 55 | } 56 | 57 | function _gh_co() { 58 | local repo=$1 59 | local pr=$2 60 | 61 | if [[ -z ${repo} ]]; then 62 | echo "Repo is required" >&2 63 | return 1 64 | fi 65 | 66 | if [[ -z ${pr} ]]; then 67 | echo "PR is required" >&2 68 | return 1 69 | fi 70 | 71 | echo "Checking out PR #$pr in data-dashboard-$repo" 72 | 73 | cd $root/../data-dashboard-$repo 74 | gh co $pr 75 | 76 | cd $root 77 | 78 | } 79 | 80 | function _gh_get_repos() { 81 | local repos=("data-dashboard-api" 82 | "data-dashboard-frontend" 83 | "data-dashboard-infra") 84 | 85 | echo ${repos[@]} 86 | } 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /scripts/_secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function _secrets_help() { 4 | echo 5 | echo "uhd secrets [options]" 6 | echo 7 | echo "commands:" 8 | echo " help - this help screen" 9 | echo 10 | echo " delete-secret - delete a secret by id" 11 | echo " delete-all-secrets - delete all secrets for the given environment" 12 | 13 | return 0 14 | } 15 | 16 | function _secrets() { 17 | local verb=$1 18 | local args=(${@:2}) 19 | 20 | case $verb in 21 | "delete-secret") _delete_secret $args ;; 22 | "delete-all-secrets") _delete_all_secrets $args ;; 23 | 24 | *) _secrets_help ;; 25 | esac 26 | } 27 | 28 | function _delete_secret() { 29 | local secret_id=$1 30 | 31 | if [[ -z ${secret_id} ]]; then 32 | echo "Secret ID is required" >&2 33 | return 1 34 | fi 35 | 36 | aws secretsmanager delete-secret --secret-id "$secret_id" --force-delete-without-recovery 37 | } 38 | 39 | function _delete_all_secrets() { 40 | local env=$1 41 | 42 | if [[ -z ${env} ]]; then 43 | echo "Env is required" >&2 44 | return 1 45 | fi 46 | 47 | secret_ids=("uhd-${env}-private-api-key" 48 | "uhd-${env}-cms-admin-user-credentials" 49 | "uhd-${env}-backend-cryptographic-signing-key" 50 | "uhd-${env}-cdn-front-end-secure-header-value" 51 | "uhd-${env}-cdn-public-api-secure-header-value" 52 | "uhd-${env}-private-api-email-credentials" 53 | "uhd-${env}-google-analytics-credentials" 54 | "uhd-${env}-aurora-db-feature-flags-credentials" 55 | "uhd-${env}-feature-flags-admin-user-credentials" 56 | "uhd-${env}-feature-flags-api-keys" 57 | "uhd-${env}-esri-api-key" 58 | "uhd-${env}-esri-maps-service-credentials" 59 | "uhd-${env}-slack-webhook-url") 60 | 61 | for ((i=1; i<=${#secret_ids[@]}; ++i)); do 62 | _delete_secret "${secret_ids[i]}" 63 | done 64 | } 65 | -------------------------------------------------------------------------------- /terraform/20-app/lambda.alarm-notification.tf: -------------------------------------------------------------------------------- 1 | module "lambda_alarm_notification" { 2 | source = "terraform-aws-modules/lambda/aws" 3 | version = "7.8.1" 4 | function_name = "${local.prefix}-alarm-notification" 5 | description = "Sends notifications when Cloudwatch alarms from key services are raised." 6 | 7 | create_package = true 8 | runtime = "nodejs18.x" 9 | handler = "index.handler" 10 | source_path = "../../src/lambda-alarm-notification" 11 | 12 | architectures = ["arm64"] 13 | maximum_retry_attempts = 1 14 | 15 | environment_variables = { 16 | SECRETS_MANAGER_SLACK_WEBHOOK_URL_ARN = aws_secretsmanager_secret.slack_webhook_url.arn 17 | } 18 | 19 | attach_policy_statements = true 20 | policy_statements = { 21 | subscribe_to_sns_for_alarms = { 22 | actions = ["sns:Subscribe", "sns:Receive"] 23 | effect = "Allow" 24 | resources = [ 25 | aws_sns_topic.cloudfront_alarms.arn, 26 | module.sns_topic_alarms.topic_arn, 27 | ] 28 | } 29 | get_slack_webhook_url_from_secrets_manager = { 30 | effect = "Allow", 31 | actions = ["secretsmanager:GetSecretValue"], 32 | resources = [aws_secretsmanager_secret.slack_webhook_url.arn] 33 | } 34 | } 35 | 36 | create_current_version_allowed_triggers = false 37 | allowed_triggers = { 38 | sns_cloudfront_alarms = { 39 | principal = "sns.amazonaws.com" 40 | source_arn = aws_sns_topic.cloudfront_alarms.arn 41 | } 42 | sns_aurora_db_alarms = { 43 | principal = "sns.amazonaws.com" 44 | source_arn = module.sns_topic_alarms.topic_arn 45 | } 46 | } 47 | } 48 | 49 | module "lambda_alarm_notification_security_group" { 50 | source = "terraform-aws-modules/security-group/aws" 51 | version = "5.1.0" 52 | 53 | name = "${local.prefix}-lambda-alarm-notification" 54 | vpc_id = module.vpc.vpc_id 55 | 56 | egress_with_cidr_blocks = [ 57 | { 58 | description = "https to internet" 59 | rule = "https-443-tcp" 60 | cidr_blocks = "0.0.0.0/0" 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /terraform/20-app/kms.tf: -------------------------------------------------------------------------------- 1 | module "kms_app_rds" { 2 | source = "terraform-aws-modules/kms/aws" 3 | version = "3.1.0" 4 | 5 | description = "RDS encryption key" 6 | key_usage = "ENCRYPT_DECRYPT" 7 | deletion_window_in_days = 7 8 | multi_region = false 9 | 10 | key_owners = ["arn:aws:iam::${var.tools_account_id}:root"] 11 | 12 | aliases = ["${local.prefix}-app-rds"] 13 | aliases_use_name_prefix = true 14 | } 15 | 16 | module "kms_secrets_app_engineer" { 17 | source = "terraform-aws-modules/kms/aws" 18 | version = "3.1.0" 19 | 20 | description = "Encryption key secrets needed by application engineers" 21 | key_usage = "ENCRYPT_DECRYPT" 22 | deletion_window_in_days = 7 23 | multi_region = false 24 | 25 | key_owners = [ 26 | "arn:aws:iam::${var.tools_account_id}:root" 27 | ] 28 | key_service_users = [ 29 | module.ecs_service_front_end.task_exec_iam_role_arn, 30 | module.ecs_service_feedback_api.task_exec_iam_role_arn, 31 | module.ecs_service_public_api.task_exec_iam_role_arn, 32 | module.ecs_service_private_api.task_exec_iam_role_arn, 33 | module.ecs_service_cms_admin.task_exec_iam_role_arn, 34 | ] 35 | 36 | aliases = ["${local.prefix}-secrets-app-engineer"] 37 | aliases_use_name_prefix = true 38 | } 39 | 40 | module "kms_secrets_app_operator" { 41 | source = "terraform-aws-modules/kms/aws" 42 | version = "3.1.0" 43 | 44 | description = "Encryption key secrets needed by application operator" 45 | key_usage = "ENCRYPT_DECRYPT" 46 | deletion_window_in_days = 7 47 | multi_region = false 48 | 49 | key_owners = [ 50 | "arn:aws:iam::${var.tools_account_id}:root" 51 | ] 52 | key_service_users = [ 53 | module.ecs_service_front_end.task_exec_iam_role_arn, 54 | module.ecs_service_feature_flags.task_exec_iam_role_arn, 55 | module.ecs_service_cms_admin.task_exec_iam_role_arn, 56 | ] 57 | 58 | aliases = ["${local.prefix}-secrets-app-operator"] 59 | aliases_use_name_prefix = true 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/flush-caches.yml: -------------------------------------------------------------------------------- 1 | name: Flush Caches Workflow 2 | run-name: Flush caches for `${{ inputs.environment }}` environment 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | environment: 8 | required: true 9 | type: choice 10 | description: Select a well known environment to flush all caches for. 11 | options: 12 | - prod 13 | - dev 14 | - dpd 15 | - test 16 | - staging 17 | 18 | env: 19 | AWS_REGION: "eu-west-2" 20 | 21 | permissions: 22 | id-token: write 23 | contents: read 24 | 25 | jobs: 26 | flush_caches: 27 | name: Flush caches 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: ./.github/actions/setup-terraform 32 | - uses: ./.github/actions/setup-zsh 33 | 34 | - name: Configure AWS credentials for tools account 35 | uses: ./.github/actions/configure-aws-credentials 36 | with: 37 | aws-region: ${{ env.AWS_REGION }} 38 | tools-account-role: ${{ secrets.UHD_TERRAFORM_IAM_ROLE }} 39 | 40 | - name: Terraform output 41 | run: | 42 | source uhd.sh 43 | uhd terraform init:layer 20-app 44 | uhd terraform output ${{ inputs.environment }} 45 | shell: zsh {0} 46 | 47 | - name: Configure AWS credentials for account 48 | uses: ./.github/actions/configure-aws-credentials 49 | with: 50 | account-name: ${{ inputs.environment }} 51 | aws-region: ${{ env.AWS_REGION }} 52 | prod-account-role: ${{ secrets.UHD_TERRAFORM_ROLE_PROD }} 53 | dev-account-role: ${{ secrets.UHD_TERRAFORM_ROLE_DEV }} 54 | test-account-role: ${{ secrets.UHD_TERRAFORM_ROLE_TEST }} 55 | uat-account-role: ${{ secrets.UHD_TERRAFORM_ROLE_UAT }} 56 | 57 | - name: Flush caches 58 | run: | 59 | source uhd.sh 60 | uhd cache flush 61 | shell: zsh {0} 62 | 63 | - name: Restart front end 64 | run: | 65 | source uhd.sh 66 | uhd ecs restart-containers front_end 67 | shell: zsh {0} 68 | -------------------------------------------------------------------------------- /terraform/10-account/route-53.prod-account.tf: -------------------------------------------------------------------------------- 1 | module "route_53_records" { 2 | source = "terraform-aws-modules/route53/aws//modules/records" 3 | version = "3.1.0" 4 | 5 | create = local.account == "prod" 6 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.account_dns_name] 7 | 8 | records = [ 9 | { 10 | name = "dev" 11 | type = "NS" 12 | ttl = 300 13 | records = local.account_states.dev.dns.account.name_servers 14 | }, 15 | { 16 | name = "test" 17 | type = "NS" 18 | ttl = 300 19 | records = local.account_states.test.dns.account.name_servers 20 | }, 21 | { 22 | name = "pen" 23 | type = "NS" 24 | ttl = 300 25 | records = local.account_states.test.dns.wke.pen.name_servers 26 | }, 27 | { 28 | name = "perf" 29 | type = "NS" 30 | ttl = 300 31 | records = local.account_states.test.dns.wke.perf.name_servers 32 | }, 33 | { 34 | name = "uat" 35 | type = "NS" 36 | ttl = 300 37 | records = local.account_states.uat.dns.account.name_servers 38 | }, 39 | { 40 | name = "train" 41 | type = "NS" 42 | ttl = 300 43 | records = local.account_states.uat.dns.wke.train.name_servers 44 | } 45 | ] 46 | } 47 | 48 | module "route_53_records_legacy" { 49 | source = "terraform-aws-modules/route53/aws//modules/records" 50 | version = "3.1.0" 51 | 52 | create = local.account == "prod" 53 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.legacy_account_dns_name] 54 | 55 | records = [ 56 | { 57 | name = "dev" 58 | type = "NS" 59 | ttl = 300 60 | records = local.account_states.dev.dns.legacy.name_servers 61 | }, 62 | { 63 | name = "test" 64 | type = "NS" 65 | ttl = 300 66 | records = local.account_states.test.dns.legacy.name_servers 67 | }, 68 | { 69 | name = "uat" 70 | type = "NS" 71 | ttl = 300 72 | records = local.account_states.uat.dns.legacy.name_servers 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /terraform/10-account/s3.cur-exports.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | s3_cur_exports_bucket_name = "uhd-aws-cur-exports-${local.account_id}" 3 | 4 | green_ops_bucket_arn = "arn:aws:s3:::qat-cost-usage-reports" 5 | } 6 | 7 | module "s3_cur_exports" { 8 | source = "terraform-aws-modules/s3-bucket/aws" 9 | version = "4.1.2" 10 | 11 | bucket = local.s3_cur_exports_bucket_name 12 | 13 | attach_deny_insecure_transport_policy = true 14 | attach_policy = true 15 | create_bucket = local.ship_cur_to_green_ops_dashboard 16 | force_destroy = true 17 | 18 | replication_configuration = { 19 | role = module.iam_green_ops_s3_replication_role.iam_role_arn 20 | 21 | rule = { 22 | delete_marker_replication_status = "Enabled" 23 | id = "green-ops-dashboard" 24 | 25 | destination = { 26 | bucket = local.green_ops_bucket_arn 27 | } 28 | 29 | filter = { 30 | prefix = "cur" 31 | } 32 | } 33 | } 34 | 35 | versioning = { 36 | enabled = true 37 | } 38 | 39 | policy = jsonencode({ 40 | Version = "2012-10-17", 41 | Statement = [ 42 | { 43 | Effect = "Allow", 44 | Principal = { 45 | Service = "billingreports.amazonaws.com" 46 | }, 47 | Action = [ 48 | "s3:GetBucketAcl", 49 | "s3:GetBucketPolicy" 50 | ], 51 | Resource = "arn:aws:s3:::${local.s3_cur_exports_bucket_name}", 52 | Condition = { 53 | StringEquals = { 54 | "aws:SourceAccount" : local.account_id, 55 | "aws:SourceArn" : "arn:aws:cur:us-east-1:${local.account_id}:definition/*" 56 | } 57 | } 58 | }, 59 | { 60 | Effect = "Allow", 61 | Principal = { 62 | Service = "billingreports.amazonaws.com" 63 | }, 64 | Action = "s3:PutObject", 65 | Resource = "arn:aws:s3:::${local.s3_cur_exports_bucket_name}/*", 66 | Condition = { 67 | StringEquals = { 68 | "aws:SourceAccount" : local.account_id, 69 | "aws:SourceArn" : "arn:aws:cur:us-east-1:${local.account_id}:definition/*" 70 | } 71 | } 72 | } 73 | ] 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /terraform/20-app/alb.cms-admin.tf: -------------------------------------------------------------------------------- 1 | module "cms_admin_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-cms-admin" 6 | 7 | load_balancer_type = "application" 8 | 9 | vpc_id = module.vpc.vpc_id 10 | subnets = module.vpc.public_subnets 11 | drop_invalid_header_fields = true 12 | enable_deletion_protection = false 13 | 14 | access_logs = { 15 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 16 | enabled = true 17 | prefix = "cms-admin-alb" 18 | } 19 | 20 | target_groups = { 21 | "${local.prefix}-cms-admin-tg" = { 22 | name = "${local.prefix}-cms-admin-tg" 23 | backend_protocol = "HTTP" 24 | backend_port = 80 25 | target_type = "ip" 26 | create_attachment = false 27 | health_check = { 28 | enabled = true 29 | interval = 30 30 | path = "/health/" 31 | port = "traffic-port" 32 | healthy_threshold = 3 33 | unhealthy_threshold = 3 34 | timeout = 5 35 | protocol = "HTTP" 36 | matcher = "200" 37 | } 38 | } 39 | } 40 | 41 | listeners = { 42 | "${local.prefix}-cms-admin-alb-listener" = { 43 | name = "${local.prefix}-cms-admin-alb-listener" 44 | port = 443 45 | protocol = "HTTPS" 46 | certificate_arn = local.certificate_arn 47 | target_group_index = 0 48 | ssl_policy = local.alb_security_policy 49 | forward = { 50 | target_group_key = "${local.prefix}-cms-admin-tg" 51 | } 52 | } 53 | } 54 | 55 | security_group_ingress_rules = { 56 | ingress_from_internet = { 57 | from_port = 443 58 | to_port = 443 59 | ip_protocol = "tcp" 60 | cidr_ipv4 = "0.0.0.0/0" 61 | } 62 | } 63 | security_group_egress_rules = { 64 | egress_to_tasks = { 65 | ip_protocol = "tcp" 66 | from_port = 80 67 | to_port = 80 68 | referenced_security_group_id = module.ecs_service_cms_admin.security_group_id 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /uhd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | root=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) 4 | 5 | for script_file in "$root"/scripts/_*.sh; do 6 | source $script_file 7 | done 8 | 9 | function _uhd_commands_help() { 10 | 11 | echo 12 | echo " █░█ █▄▀ █░█ █▀ ▄▀█ " 13 | echo " █▄█ █░█ █▀█ ▄█ █▀█ " 14 | echo 15 | echo " █▀▄ ▄▀█ ▀█▀ ▄▀█   █▀▄ ▄▀█ █▀ █░█ █▄▄ █▀█ ▄▀█ █▀█ █▀▄ " 16 | echo " █▄▀ █▀█ ░█░ █▀█   █▄▀ █▀█ ▄█ █▀█ █▄█ █▄█ █▀█ █▀▄ █▄▀ " 17 | echo 18 | echo " █▀▀ █░░ █" 19 | echo " █▄▄ █▄▄ █" 20 | echo 21 | echo " UKHSA Data Dashboard CLI Tool" 22 | echo 23 | echo "uhd [options]" 24 | echo 25 | echo "commands:" 26 | echo " help - this help screen" 27 | echo 28 | echo " aws - aws commands" 29 | echo " cache - cache commands" 30 | echo " data - upload and ingest metric data" 31 | echo " docker - docker commands" 32 | echo " ecs - aws ecs commands" 33 | echo " gh - github commands" 34 | echo " lambda - aws lambda commands" 35 | echo " terraform - terraform commands" 36 | echo " secrets - aws secrets commands" 37 | 38 | echo 39 | echo " update - update all the things - infra, containers, etc" 40 | echo 41 | 42 | return 0 43 | } 44 | 45 | function uhd() { 46 | if [ $CI ]; then 47 | echo $0 $@ 48 | fi 49 | 50 | local current=$(pwd) 51 | local command=$1 52 | local args=(${@:2}) 53 | 54 | cd $root 55 | 56 | case $command in 57 | "aws") _aws $args ;; 58 | "cache") _cache $args ;; 59 | "data") _data $args ;; 60 | "docker") _docker $args ;; 61 | "ecs") _ecs $args ;; 62 | "gh") _gh $args ;; 63 | "lambda") _lambda $args ;; 64 | "terraform") _terraform $args ;; 65 | "secrets") _secrets $args ;; 66 | "update") _update $args ;; 67 | 68 | *) _uhd_commands_help ;; 69 | esac 70 | 71 | local exit_code=$? 72 | 73 | cd $current 74 | 75 | return $exit_code 76 | } 77 | 78 | echo 79 | echo "uhd cli loaded" 80 | echo 81 | echo "Type uhd for the help screen" 82 | echo 83 | 84 | return 0 -------------------------------------------------------------------------------- /terraform/10-account/iam.operations-role.tf: -------------------------------------------------------------------------------- 1 | module "iam_operations_role" { 2 | source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role" 3 | version = "5.44.0" 4 | 5 | create_role = true 6 | max_session_duration = 43200 7 | role_name = "Operations" 8 | role_requires_mfa = false 9 | 10 | custom_role_policy_arns = [ 11 | "arn:aws:iam::aws:policy/ReadOnlyAccess", 12 | module.iam_operations_policy.arn 13 | ] 14 | 15 | trusted_role_arns = local.account == "dev" ? [ 16 | local.sso_role_arns.administrator, 17 | local.sso_role_arns.developer, 18 | local.sso_role_arns.operations, 19 | ] : [ 20 | local.sso_role_arns.administrator, 21 | local.sso_role_arns.operations, 22 | ] 23 | } 24 | 25 | module "iam_operations_policy" { 26 | source = "terraform-aws-modules/iam/aws//modules/iam-policy" 27 | version = "5.44.0" 28 | 29 | name = "uhd-operations-policy" 30 | 31 | policy = jsonencode( 32 | { 33 | Version = "2012-10-17", 34 | Statement = [ 35 | { 36 | Action = ["s3:PutObject"], 37 | Effect = "Allow", 38 | Resource = "arn:aws:s3:::uhd-*-ingest/in/*.json" 39 | }, 40 | { 41 | Action = ["s3:DeleteObject"], 42 | Effect = "Allow", 43 | Resource = "arn:aws:s3:::uhd-*-ingest/failed/*" 44 | }, 45 | { 46 | Action = ["s3:DeleteObject"], 47 | Effect = "Allow", 48 | Resource = "arn:aws:s3:::uhd-*-ingest/processed/*" 49 | }, 50 | { 51 | Action = [ 52 | "cloudfront:CreateInvalidation", 53 | "cloudfront:GetInvalidation", 54 | "ecs:DescribeTasks", 55 | "ecs:ExecuteCommand", 56 | "ecs:RunTask", 57 | "iam:PassRole", 58 | "logs:StartLiveTail", 59 | "logs:StopLiveTail" 60 | ], 61 | Effect = "Allow", 62 | Resource = "*" 63 | }, 64 | { 65 | Action = [ 66 | "s3:DeleteObject", 67 | "s3:GetObject", 68 | "s3:PutObject", 69 | ], 70 | Effect = "Allow", 71 | Resource = "arn:aws:s3:::uhd-*-archive-web-content/*" 72 | } 73 | ] 74 | } 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /terraform/20-app/ecs.service.bastion.tf: -------------------------------------------------------------------------------- 1 | module "ecs_service_bastion" { 2 | source = "terraform-aws-modules/ecs/aws//modules/service" 3 | version = "5.11.4" 4 | 5 | name = "${local.prefix}-bastion" 6 | cluster_arn = module.ecs.cluster_arn 7 | enable_execute_command = true 8 | 9 | cpu = 256 10 | memory = 512 11 | subnet_ids = module.vpc.private_subnets 12 | 13 | enable_autoscaling = false 14 | desired_count = 0 15 | 16 | runtime_platform = { 17 | cpu_architecture = "ARM64" 18 | operating_system_family = "LINUX" 19 | } 20 | 21 | container_definitions = { 22 | bastion = { 23 | cloudwatch_log_group_retention_in_days = local.default_log_retention_in_days 24 | command = ["sleep", "infinity"] 25 | cpu = 256 26 | essential = true 27 | image = "public.ecr.aws/amazonlinux/amazonlinux:2023" 28 | memory = 512 29 | readonly_root_filesystem = false 30 | } 31 | } 32 | 33 | tasks_iam_role_statements = [ 34 | { 35 | actions = [ 36 | "ssmmessages:CreateControlChannel", 37 | "ssmmessages:CreateDataChannel", 38 | "ssmmessages:OpenControlChannel", 39 | "ssmmessages:OpenDataChannel" 40 | ] 41 | resources = ["*"] 42 | } 43 | ] 44 | 45 | task_exec_iam_statements = { 46 | kms_keys = { 47 | actions = ["kms:Decrypt"] 48 | resources = [ 49 | module.kms_secrets_app_engineer.key_arn, 50 | module.kms_app_rds.key_arn, 51 | module.kms_secrets_app_operator.key_arn, 52 | ] 53 | } 54 | } 55 | 56 | security_group_rules = { 57 | # egress rules 58 | internet_https_egress = { 59 | type = "egress" 60 | from_port = 443 61 | to_port = 443 62 | protocol = "tcp" 63 | description = "https to internet" 64 | cidr_blocks = ["0.0.0.0/0"] 65 | } 66 | internet_http_egress = { 67 | type = "egress" 68 | from_port = 80 69 | to_port = 80 70 | protocol = "tcp" 71 | description = "http to internet" 72 | cidr_blocks = ["0.0.0.0/0"] 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /terraform/20-app/route-53.wke-account.tf: -------------------------------------------------------------------------------- 1 | module "route_53_records_wke_account" { 2 | source = "terraform-aws-modules/route53/aws//modules/records" 3 | version = "3.1.0" 4 | 5 | create = contains(local.wke.account, local.environment) 6 | zone_id = local.account_layer.dns.account.zone_id 7 | 8 | records = [ 9 | { 10 | name = "" 11 | type = "A" 12 | alias = { 13 | name = module.cloudfront_front_end.cloudfront_distribution_domain_name 14 | zone_id = module.cloudfront_front_end.cloudfront_distribution_hosted_zone_id 15 | } 16 | }, 17 | { 18 | name = "lb" 19 | type = "A" 20 | alias = { 21 | name = module.front_end_alb.dns_name 22 | zone_id = module.front_end_alb.zone_id 23 | } 24 | }, 25 | { 26 | name = "api" 27 | type = "A" 28 | alias = { 29 | name = module.cloudfront_front_end.cloudfront_distribution_domain_name 30 | zone_id = module.cloudfront_front_end.cloudfront_distribution_hosted_zone_id 31 | } 32 | }, 33 | { 34 | name = "api-lb" 35 | type = "A" 36 | alias = { 37 | name = module.public_api_alb.dns_name 38 | zone_id = module.public_api_alb.zone_id 39 | } 40 | }, 41 | { 42 | name = "private-api" 43 | type = "A" 44 | alias = { 45 | name = module.private_api_alb.dns_name 46 | zone_id = module.private_api_alb.zone_id 47 | } 48 | }, 49 | { 50 | name = "feedback-api" 51 | type = "A" 52 | alias = { 53 | name = module.feedback_api_alb.dns_name 54 | zone_id = module.feedback_api_alb.zone_id 55 | } 56 | }, 57 | { 58 | name = "cms" 59 | type = "A" 60 | alias = { 61 | name = module.cms_admin_alb.dns_name 62 | zone_id = module.cms_admin_alb.zone_id 63 | } 64 | }, 65 | { 66 | name = "archive" 67 | type = "A" 68 | alias = { 69 | name = module.cloudfront_archive_web_content.cloudfront_distribution_domain_name 70 | zone_id = module.cloudfront_archive_web_content.cloudfront_distribution_hosted_zone_id 71 | } 72 | }, 73 | { 74 | name = "feature-flags" 75 | type = "A" 76 | alias = { 77 | name = module.feature_flags_alb.dns_name 78 | zone_id = module.feature_flags_alb.zone_id 79 | } 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /terraform/20-app/route-53.wke-others.tf: -------------------------------------------------------------------------------- 1 | module "route_53_records_wke_others" { 2 | source = "terraform-aws-modules/route53/aws//modules/records" 3 | version = "3.1.0" 4 | 5 | create = contains(local.wke.other, local.environment) 6 | zone_id = contains(local.wke.other, local.environment) ? local.account_layer.dns.wke[local.environment].zone_id : "" 7 | 8 | records = [ 9 | { 10 | name = "" 11 | type = "A" 12 | alias = { 13 | name = module.cloudfront_front_end.cloudfront_distribution_domain_name 14 | zone_id = module.cloudfront_front_end.cloudfront_distribution_hosted_zone_id 15 | } 16 | }, 17 | { 18 | name = "lb" 19 | type = "A" 20 | alias = { 21 | name = module.front_end_alb.dns_name 22 | zone_id = module.front_end_alb.zone_id 23 | } 24 | }, 25 | { 26 | name = "api" 27 | type = "A" 28 | alias = { 29 | name = module.cloudfront_front_end.cloudfront_distribution_domain_name 30 | zone_id = module.cloudfront_front_end.cloudfront_distribution_hosted_zone_id 31 | } 32 | }, 33 | { 34 | name = "api-lb" 35 | type = "A" 36 | alias = { 37 | name = module.public_api_alb.dns_name 38 | zone_id = module.public_api_alb.zone_id 39 | } 40 | }, 41 | { 42 | name = "private-api" 43 | type = "A" 44 | alias = { 45 | name = module.private_api_alb.dns_name 46 | zone_id = module.private_api_alb.zone_id 47 | } 48 | }, 49 | { 50 | name = "feedback-api" 51 | type = "A" 52 | alias = { 53 | name = module.feedback_api_alb.dns_name 54 | zone_id = module.feedback_api_alb.zone_id 55 | } 56 | }, 57 | { 58 | name = "cms" 59 | type = "A" 60 | alias = { 61 | name = module.cms_admin_alb.dns_name 62 | zone_id = module.cms_admin_alb.zone_id 63 | } 64 | }, 65 | { 66 | name = "archive" 67 | type = "A" 68 | alias = { 69 | name = module.cloudfront_archive_web_content.cloudfront_distribution_domain_name 70 | zone_id = module.cloudfront_archive_web_content.cloudfront_distribution_hosted_zone_id 71 | } 72 | }, 73 | { 74 | name = "feature-flags" 75 | type = "A" 76 | alias = { 77 | name = module.feature_flags_alb.dns_name 78 | zone_id = module.feature_flags_alb.zone_id 79 | } 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /src/legacy-dashboard-redirect-viewer-request/index.test.js: -------------------------------------------------------------------------------- 1 | const originRequest = require("./index"); 2 | const handler = originRequest.__get__("handler"); 3 | 4 | test.each([ 5 | { 6 | host: "491aa455.dev.coronavirus.data.gov.uk", 7 | redirectTo: "https://491aa455.dev.ukhsa-dashboard.data.gov.uk", 8 | }, 9 | { 10 | host: "dev.coronavirus.data.gov.uk", 11 | redirectTo: "https://dev.ukhsa-dashboard.data.gov.uk", 12 | }, 13 | { 14 | host: "test.coronavirus.data.gov.uk", 15 | redirectTo: "https://test.ukhsa-dashboard.data.gov.uk", 16 | }, 17 | { 18 | host: "uat.coronavirus.data.gov.uk", 19 | redirectTo: "https://uat.ukhsa-dashboard.data.gov.uk", 20 | }, 21 | { 22 | host: "coronavirus.data.gov.uk", 23 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 24 | }, 25 | { 26 | host: "d111111abcdef8.cloudfront.net", 27 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 28 | }, 29 | ])( 30 | "Host header '$host' should redirect to '$redirectTo'", 31 | ({ host, redirectTo }) => { 32 | const event = { 33 | version: "1.0", 34 | request: { 35 | uri: "/", 36 | headers: { 37 | host: { value: host }, 38 | }, 39 | }, 40 | }; 41 | 42 | const expected = { 43 | statusCode: 301, 44 | statusDescription: "Moved Permanently", 45 | headers: { location: { value: redirectTo } }, 46 | }; 47 | 48 | const result = handler(event); 49 | 50 | expect(result).toEqual(expected); 51 | } 52 | ); 53 | 54 | test.each([ 55 | { 56 | path: "/", 57 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 58 | }, 59 | { 60 | path: "/details/testing", 61 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 62 | }, 63 | { 64 | path: "/details/interactive-map/cases", 65 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 66 | }, 67 | { 68 | path: "/foo/bar", 69 | redirectTo: "https://ukhsa-dashboard.data.gov.uk", 70 | }, 71 | ])("Path '$path' should redirect to '$redirectTo'", ({ path, redirectTo }) => { 72 | const event = { 73 | version: "1.0", 74 | request: { 75 | uri: path, 76 | headers: { 77 | host: { value: "coronavirus.data.gov.uk" }, 78 | }, 79 | }, 80 | }; 81 | 82 | const expected = { 83 | statusCode: 301, 84 | statusDescription: "Moved Permanently", 85 | headers: { location: { value: redirectTo } }, 86 | }; 87 | 88 | const result = handler(event); 89 | 90 | expect(result).toEqual(expected); 91 | }); 92 | -------------------------------------------------------------------------------- /athena/tables/cms-admin-alb-access-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-cms-admin-alb-access-logs` ( 7 | type string, 8 | time string, 9 | elb string, 10 | client_ip string, 11 | client_port int, 12 | target_ip string, 13 | target_port int, 14 | request_processing_time double, 15 | target_processing_time double, 16 | response_processing_time double, 17 | elb_status_code int, 18 | target_status_code string, 19 | received_bytes bigint, 20 | sent_bytes bigint, 21 | request_verb string, 22 | request_url string, 23 | request_proto string, 24 | user_agent string, 25 | ssl_cipher string, 26 | ssl_protocol string, 27 | target_group_arn string, 28 | trace_id string, 29 | domain_name string, 30 | chosen_cert_arn string, 31 | matched_rule_priority string, 32 | request_creation_time string, 33 | actions_executed string, 34 | redirect_url string, 35 | lambda_error_reason string, 36 | target_port_list string, 37 | target_status_code_list string, 38 | classification string, 39 | classification_reason string 40 | ) PARTITIONED BY (day STRING) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' 41 | WITH 42 | SERDEPROPERTIES ( 43 | 'serialization.format' = '1', 44 | 'input.regex' = '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^\s]+?)\" \"([^\s]+)\" \"([^ ]*)\" \"([^ ]*)\"' 45 | ) LOCATION 's3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/cms-admin-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/' TBLPROPERTIES ( 46 | "projection.enabled" = "true", 47 | "projection.day.type" = "date", 48 | "projection.day.range" = "2022/01/01,NOW", 49 | "projection.day.format" = "yyyy/MM/dd", 50 | "projection.day.interval" = "1", 51 | "projection.day.interval.unit" = "DAYS", 52 | "storage.location.template" = "s3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/cms-admin-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/${day}" 53 | ) -------------------------------------------------------------------------------- /athena/tables/front-end-alb-access-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-front-end-alb-access-logs` ( 7 | type string, 8 | time string, 9 | elb string, 10 | client_ip string, 11 | client_port int, 12 | target_ip string, 13 | target_port int, 14 | request_processing_time double, 15 | target_processing_time double, 16 | response_processing_time double, 17 | elb_status_code int, 18 | target_status_code string, 19 | received_bytes bigint, 20 | sent_bytes bigint, 21 | request_verb string, 22 | request_url string, 23 | request_proto string, 24 | user_agent string, 25 | ssl_cipher string, 26 | ssl_protocol string, 27 | target_group_arn string, 28 | trace_id string, 29 | domain_name string, 30 | chosen_cert_arn string, 31 | matched_rule_priority string, 32 | request_creation_time string, 33 | actions_executed string, 34 | redirect_url string, 35 | lambda_error_reason string, 36 | target_port_list string, 37 | target_status_code_list string, 38 | classification string, 39 | classification_reason string 40 | ) PARTITIONED BY (day STRING) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' 41 | WITH 42 | SERDEPROPERTIES ( 43 | 'serialization.format' = '1', 44 | 'input.regex' = '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^\s]+?)\" \"([^\s]+)\" \"([^ ]*)\" \"([^ ]*)\"' 45 | ) LOCATION 's3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/front-end-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/' TBLPROPERTIES ( 46 | "projection.enabled" = "true", 47 | "projection.day.type" = "date", 48 | "projection.day.range" = "2022/01/01,NOW", 49 | "projection.day.format" = "yyyy/MM/dd", 50 | "projection.day.interval" = "1", 51 | "projection.day.interval.unit" = "DAYS", 52 | "storage.location.template" = "s3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/front-end-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/${day}" 53 | ) -------------------------------------------------------------------------------- /athena/tables/public-api-alb-access-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-public-api-alb-access-logs` ( 7 | type string, 8 | time string, 9 | elb string, 10 | client_ip string, 11 | client_port int, 12 | target_ip string, 13 | target_port int, 14 | request_processing_time double, 15 | target_processing_time double, 16 | response_processing_time double, 17 | elb_status_code int, 18 | target_status_code string, 19 | received_bytes bigint, 20 | sent_bytes bigint, 21 | request_verb string, 22 | request_url string, 23 | request_proto string, 24 | user_agent string, 25 | ssl_cipher string, 26 | ssl_protocol string, 27 | target_group_arn string, 28 | trace_id string, 29 | domain_name string, 30 | chosen_cert_arn string, 31 | matched_rule_priority string, 32 | request_creation_time string, 33 | actions_executed string, 34 | redirect_url string, 35 | lambda_error_reason string, 36 | target_port_list string, 37 | target_status_code_list string, 38 | classification string, 39 | classification_reason string 40 | ) PARTITIONED BY (day STRING) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' 41 | WITH 42 | SERDEPROPERTIES ( 43 | 'serialization.format' = '1', 44 | 'input.regex' = '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^\s]+?)\" \"([^\s]+)\" \"([^ ]*)\" \"([^ ]*)\"' 45 | ) LOCATION 's3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/public-api-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/' TBLPROPERTIES ( 46 | "projection.enabled" = "true", 47 | "projection.day.type" = "date", 48 | "projection.day.range" = "2022/01/01,NOW", 49 | "projection.day.format" = "yyyy/MM/dd", 50 | "projection.day.interval" = "1", 51 | "projection.day.interval.unit" = "DAYS", 52 | "storage.location.template" = "s3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/public-api-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/${day}" 53 | ) -------------------------------------------------------------------------------- /athena/tables/private-api-alb-access-logs.sql: -------------------------------------------------------------------------------- 1 | /* 2 | * Athena doesn't support sql variables, so you'll need to find and replace the following tokens before running: 3 | * - {ENV NAME} => environment name 4 | * - {ACCOUNT ID} => aws account id you are deploying into 5 | */ 6 | CREATE EXTERNAL TABLE IF NOT EXISTS `uhd-{ENV NAME}-private-api-alb-access-logs` ( 7 | type string, 8 | time string, 9 | elb string, 10 | client_ip string, 11 | client_port int, 12 | target_ip string, 13 | target_port int, 14 | request_processing_time double, 15 | target_processing_time double, 16 | response_processing_time double, 17 | elb_status_code int, 18 | target_status_code string, 19 | received_bytes bigint, 20 | sent_bytes bigint, 21 | request_verb string, 22 | request_url string, 23 | request_proto string, 24 | user_agent string, 25 | ssl_cipher string, 26 | ssl_protocol string, 27 | target_group_arn string, 28 | trace_id string, 29 | domain_name string, 30 | chosen_cert_arn string, 31 | matched_rule_priority string, 32 | request_creation_time string, 33 | actions_executed string, 34 | redirect_url string, 35 | lambda_error_reason string, 36 | target_port_list string, 37 | target_status_code_list string, 38 | classification string, 39 | classification_reason string 40 | ) PARTITIONED BY (day STRING) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' 41 | WITH 42 | SERDEPROPERTIES ( 43 | 'serialization.format' = '1', 44 | 'input.regex' = '([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^\s]+?)\" \"([^\s]+)\" \"([^ ]*)\" \"([^ ]*)\"' 45 | ) LOCATION 's3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/private-api-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/' TBLPROPERTIES ( 46 | "projection.enabled" = "true", 47 | "projection.day.type" = "date", 48 | "projection.day.range" = "2022/01/01,NOW", 49 | "projection.day.format" = "yyyy/MM/dd", 50 | "projection.day.interval" = "1", 51 | "projection.day.interval.unit" = "DAYS", 52 | "storage.location.template" = "s3://uhd-aws-elb-access-logs-{ACCOUNT ID}-eu-west-2/private-api-alb/AWSLogs/{ACCOUNT ID}/elasticloadbalancing/eu-west-2/${day}" 53 | ) -------------------------------------------------------------------------------- /terraform/20-app/route-53.tf: -------------------------------------------------------------------------------- 1 | module "route_53_records" { 2 | source = "terraform-aws-modules/route53/aws//modules/records" 3 | version = "3.1.0" 4 | 5 | zone_id = local.account_layer.dns.account.zone_id 6 | 7 | records = [ 8 | { 9 | name = local.environment 10 | type = "A" 11 | alias = { 12 | name = module.cloudfront_front_end.cloudfront_distribution_domain_name 13 | zone_id = module.cloudfront_front_end.cloudfront_distribution_hosted_zone_id 14 | } 15 | }, 16 | { 17 | name = "${local.environment}-lb" 18 | type = "A" 19 | alias = { 20 | name = module.front_end_alb.dns_name 21 | zone_id = module.front_end_alb.zone_id 22 | } 23 | }, 24 | { 25 | name = "${local.environment}-api" 26 | type = "A" 27 | alias = { 28 | name = module.cloudfront_public_api.cloudfront_distribution_domain_name 29 | zone_id = module.cloudfront_public_api.cloudfront_distribution_hosted_zone_id 30 | } 31 | }, 32 | { 33 | name = "${local.environment}-api-lb" 34 | type = "A" 35 | alias = { 36 | name = module.public_api_alb.dns_name 37 | zone_id = module.public_api_alb.zone_id 38 | } 39 | }, 40 | { 41 | name = "${local.environment}-private-api", 42 | type = "A" 43 | alias = { 44 | name = module.private_api_alb.dns_name 45 | zone_id = module.private_api_alb.zone_id 46 | } 47 | }, 48 | { 49 | name = "${local.environment}-feedback-api", 50 | type = "A" 51 | alias = { 52 | name = module.feedback_api_alb.dns_name 53 | zone_id = module.feedback_api_alb.zone_id 54 | } 55 | }, 56 | { 57 | name = "${local.environment}-cms" 58 | type = "A" 59 | alias = { 60 | name = module.cms_admin_alb.dns_name 61 | zone_id = module.cms_admin_alb.zone_id 62 | } 63 | }, 64 | { 65 | name = "${local.environment}-archive" 66 | type = "A" 67 | alias = { 68 | name = module.cloudfront_archive_web_content.cloudfront_distribution_domain_name 69 | zone_id = module.cloudfront_archive_web_content.cloudfront_distribution_hosted_zone_id 70 | } 71 | }, 72 | { 73 | name = "${local.environment}-feature-flags" 74 | type = "A" 75 | alias = { 76 | name = module.feature_flags_alb.dns_name 77 | zone_id = module.feature_flags_alb.zone_id 78 | } 79 | } 80 | ] 81 | } 82 | 83 | -------------------------------------------------------------------------------- /scripts/_lambda.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function _lambda_help() { 4 | echo 5 | echo "uhd lambda [options]" 6 | echo 7 | echo "commands:" 8 | echo " help - this help screen" 9 | echo 10 | echo " restart-functions - restarts all the lambda functions, pulling most recent images in the process" 11 | echo " logs - tail logs for the specified lambda" 12 | 13 | return 0 14 | } 15 | 16 | function _lambda() { 17 | local verb=$1 18 | local args=(${@:2}) 19 | 20 | case $verb in 21 | "restart-functions") _lambda_restart_functions $args ;; 22 | "logs") _lambda_logs $args ;; 23 | 24 | *) _lambda_help ;; 25 | esac 26 | } 27 | 28 | function _lambda_logs() { 29 | local env=$1 30 | local lambda_name=$2 31 | 32 | if [[ -z ${env} ]]; then 33 | echo "Env is required" >&2 34 | return 1 35 | fi 36 | 37 | if [[ -z ${lambda_name} ]]; then 38 | echo "Lambda name is required" >&2 39 | return 1 40 | fi 41 | 42 | aws logs tail "/aws/lambda/uhd-${env}-${lambda_name}" --follow 43 | } 44 | 45 | 46 | function _lambda_restart_functions() { 47 | local ingestion_image_uri=$(_get_most_recent_ingestion_image_uri) 48 | local ingestion_lambda_arn=$(_get_ingestion_lambda_arn) 49 | 50 | echo "Deploying latest image to ingestion lambda..." 51 | aws lambda update-function-code \ 52 | --function-name $ingestion_lambda_arn \ 53 | --image-uri $ingestion_image_uri \ 54 | --no-cli-pager \ 55 | --no-cli-auto-prompt \ 56 | > /dev/null 57 | 58 | echo "Waiting for lambda to update..." 59 | aws lambda wait function-updated-v2 --function-name $ingestion_lambda_arn 60 | } 61 | 62 | function _get_most_recent_ingestion_image_uri() { 63 | source scripts/_docker.sh 64 | 65 | local terraform_output_file=terraform/20-app/output.json 66 | local ingestion_ecr_name=$(jq -r '.ecr.value.repo_names.ingestion' $terraform_output_file) 67 | local ingestion_ecr_url=$(jq -r '.ecr.value.repo_urls.ingestion' $terraform_output_file) 68 | 69 | local most_recent_ingestion_image_tag=$(_docker_get_most_recent_image_tag_from_repo ${ingestion_ecr_name}) 70 | local ingestion_image_uri="${ingestion_ecr_url}:${most_recent_ingestion_image_tag}" 71 | echo ${ingestion_image_uri} 72 | } 73 | 74 | function _get_ingestion_lambda_arn() { 75 | local terraform_output_file=terraform/20-app/output.json 76 | local ingestion_lambda_arn=$(jq -r '.lambda.value.ingestion_lambda_arn' $terraform_output_file) 77 | echo $ingestion_lambda_arn 78 | } 79 | -------------------------------------------------------------------------------- /terraform/10-account/outputs.tf: -------------------------------------------------------------------------------- 1 | output "dns" { 2 | value = { 3 | account = { 4 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.account_dns_name] 5 | name_servers = module.route_53_zone_account.route53_zone_name_servers[var.account_dns_name] 6 | dns_name = var.account_dns_name 7 | } 8 | legacy = { 9 | zone_id = module.route_53_zone_account.route53_zone_zone_id[var.legacy_account_dns_name] 10 | name_servers = module.route_53_zone_account.route53_zone_name_servers[var.legacy_account_dns_name] 11 | dns_name = var.legacy_account_dns_name 12 | } 13 | 14 | wke = { 15 | pen = local.account == "test" ? { 16 | zone_id = module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.pen] 17 | name_servers = module.route_53_zone_wke_test_account.route53_zone_name_servers[local.wke_dns_names.pen] 18 | } : null 19 | perf = local.account == "test" ? { 20 | zone_id = module.route_53_zone_wke_test_account.route53_zone_zone_id[local.wke_dns_names.perf] 21 | name_servers = module.route_53_zone_wke_test_account.route53_zone_name_servers[local.wke_dns_names.perf] 22 | } : null 23 | train = local.account == "uat" ? { 24 | zone_id = module.route_53_zone_wke_uat_account.route53_zone_zone_id[local.wke_dns_names.train] 25 | name_servers = module.route_53_zone_wke_uat_account.route53_zone_name_servers[local.wke_dns_names.train] 26 | } : null 27 | } 28 | 29 | wke_dns_names = local.wke_dns_names 30 | } 31 | } 32 | 33 | output "acm" { 34 | value = { 35 | account = { 36 | certificate_arn = module.acm_account.acm_certificate_arn 37 | cloud_front_certificate_arn = module.acm_cloud_front.acm_certificate_arn 38 | } 39 | legacy = { 40 | cloud_front_certificate_arn = module.acm_cloud_front_legacy_dashboard.acm_certificate_arn 41 | } 42 | wke = { 43 | pen = local.account == "test" ? { 44 | certificate_arn = module.acm_wke_pen.acm_certificate_arn 45 | cloud_front_certificate_arn = module.acm_cloud_front_wke_pen.acm_certificate_arn 46 | } : null 47 | perf = local.account == "test" ? { 48 | certificate_arn = module.acm_wke_perf.acm_certificate_arn 49 | cloud_front_certificate_arn = module.acm_cloud_front_wke_perf.acm_certificate_arn 50 | } : null 51 | train = local.account == "uat" ? { 52 | certificate_arn = module.acm_wke_train.acm_certificate_arn 53 | cloud_front_certificate_arn = module.acm_cloud_front_wke_train.acm_certificate_arn 54 | } : null 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/_aws.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | function _aws_help() { 5 | echo 6 | echo "uhd aws [options]" 7 | echo 8 | echo "commands:" 9 | echo " help - this help screen" 10 | echo 11 | echo " login - login to the dev and tools accounts as a developer" 12 | echo " login - login and assume the configured role" 13 | echo 14 | echo " use - switch to the specified profile" 15 | echo 16 | echo " whoami - display the account you're logged into and which role you have assumed" 17 | echo 18 | 19 | return 1 20 | } 21 | 22 | function _aws() { 23 | local verb=$1 24 | local args=(${@:2}) 25 | 26 | case $verb in 27 | "login") _aws_login $args ;; 28 | "use") _aws_use $args ;; 29 | "whoami") _aws_whoami $args ;; 30 | 31 | *) _aws_help ;; 32 | esac 33 | } 34 | 35 | function _aws_login() { 36 | local profile_name=$1 37 | if [[ -z ${profile_name} ]]; then 38 | uhd aws login uhd-dev 39 | uhd aws login uhd-tools 40 | 41 | return 0 42 | fi 43 | 44 | aws sso login --profile $profile_name 45 | 46 | echo 47 | 48 | case $profile_name in 49 | "uhd-dev" | "uhd-dev:ops" | "uhd-test" | "uhd-uat" | "uhd-prod" | "uhd-tools" | "uhd-tools:ops" ) 50 | echo "Logged into AWS using profile '$profile_name', and switched to profile '${profile_name}/assumed-role'" 51 | export AWS_PROFILE=${profile_name}/assumed-role ;; 52 | 53 | *) 54 | echo "Logged into AWS using profile '$profile_name'" 55 | export AWS_PROFILE=$profile_name ;; 56 | esac 57 | } 58 | 59 | function _aws_use() { 60 | local profile_name=$1 61 | if [[ -z ${profile_name} ]]; then 62 | echo "Profile is required" 63 | 64 | return 1 65 | fi 66 | 67 | case $profile_name in 68 | "uhd-dev" | "uhd-tools") 69 | echo "Switching to profile '${profile_name}/assumed-role'" 70 | export AWS_PROFILE=${profile_name}/assumed-role ;; 71 | 72 | *) 73 | echo "Switching to profile '$profile_name'" 74 | export AWS_PROFILE=$profile_name ;; 75 | esac 76 | } 77 | 78 | function _aws_whoami() { 79 | local env=$(_get_env_name) 80 | local urls=$(_get_public_urls) 81 | 82 | echo "Using profile $AWS_PROFILE:" 83 | 84 | aws sts get-caller-identity | jq . 85 | 86 | echo 87 | echo Connected to $env environment: 88 | echo $urls | jq . 89 | } 90 | 91 | function _get_public_urls() { 92 | local terraform_output_file=terraform/20-app/output.json 93 | local urls=$(jq -r '.urls.value | { cms_admin, front_end, public_api }' $terraform_output_file) 94 | 95 | echo $urls 96 | } 97 | -------------------------------------------------------------------------------- /terraform/20-app/cloud-front.legacy-dashboard-redirect.tf: -------------------------------------------------------------------------------- 1 | module "cloudfront_legacy_dashboard_redirect" { 2 | source = "terraform-aws-modules/cloudfront/aws" 3 | version = "3.4.0" 4 | 5 | create_distribution = !contains(["pen", "staging", "perf"], local.environment) 6 | 7 | aliases = [local.dns_names.legacy_dashboard] 8 | comment = "${local.prefix}-legacy-dashboard-redirect" 9 | enabled = true 10 | wait_for_deployment = true 11 | 12 | web_acl_id = aws_wafv2_web_acl.legacy_dashboard_redirect.arn 13 | 14 | origin = { 15 | front_end = { 16 | domain_name = "${local.dns_names.front_end}" 17 | 18 | custom_origin_config = { 19 | http_port = 80 20 | https_port = 443 21 | origin_protocol_policy = "https-only" 22 | origin_ssl_protocols = ["TLSv1.2"] 23 | } 24 | } 25 | } 26 | 27 | default_cache_behavior = { 28 | allowed_methods = ["GET", "HEAD", "OPTIONS"] 29 | cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized 30 | cached_methods = ["GET", "HEAD"] 31 | compress = true 32 | response_headers_policy_id = "eaab4381-ed33-4a86-88ca-d9558dc6cd63" # CORS-with-preflight-and-SecurityHeadersPolicy 33 | target_origin_id = "front_end" 34 | use_forwarded_values = false 35 | viewer_protocol_policy = "redirect-to-https" 36 | function_association = { 37 | viewer-request = { 38 | function_arn = aws_cloudfront_function.legacy_dashboard_redirect_viewer_request.arn 39 | } 40 | } 41 | } 42 | 43 | logging_config = { 44 | bucket = data.aws_s3_bucket.cloud_front_logs_eu_west_2.bucket_domain_name 45 | enabled = true 46 | include_cookies = false 47 | prefix = "${local.prefix}-legacy-dashboard-redirect" 48 | } 49 | 50 | viewer_certificate = { 51 | acm_certificate_arn = local.cloud_front_legacy_dashboard_certificate_arn 52 | ssl_support_method = "sni-only" 53 | minimum_protocol_version = "TLSv1.2_2021" 54 | } 55 | } 56 | 57 | resource "aws_cloudfront_function" "legacy_dashboard_redirect_viewer_request" { 58 | name = "${local.prefix}-legacy-dashboard-redirect-viewer-request" 59 | runtime = "cloudfront-js-2.0" 60 | publish = true 61 | code = file("../../src/legacy-dashboard-redirect-viewer-request/index.js") 62 | } 63 | 64 | resource "aws_cloudwatch_log_group" "cloud_front_function_legacy_dashboard_redirect_viewer_request" { 65 | name = "/aws/cloudfront/function/${aws_cloudfront_function.legacy_dashboard_redirect_viewer_request.name}" 66 | provider = aws.us_east_1 67 | retention_in_days = local.default_log_retention_in_days 68 | } 69 | -------------------------------------------------------------------------------- /terraform/20-app/waf.front-end.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "front_end" { 2 | name = "${local.prefix}-front-end" 3 | description = "Web ACL for front-end application" 4 | scope = "CLOUDFRONT" 5 | provider = aws.us_east_1 6 | 7 | default_action { 8 | dynamic "block" { 9 | for_each = local.use_ip_allow_list ? [""] : [] 10 | content { 11 | } 12 | } 13 | dynamic "allow" { 14 | for_each = local.use_ip_allow_list ? [] : [""] 15 | content { 16 | } 17 | } 18 | } 19 | 20 | dynamic "rule" { 21 | for_each = local.waf_front_end.rules 22 | 23 | content { 24 | name = rule.value.name 25 | priority = rule.value.priority 26 | 27 | override_action { 28 | none {} 29 | } 30 | 31 | statement { 32 | managed_rule_group_statement { 33 | name = rule.value.name 34 | vendor_name = "AWS" 35 | } 36 | } 37 | 38 | visibility_config { 39 | metric_name = rule.value.name 40 | cloudwatch_metrics_enabled = true 41 | sampled_requests_enabled = true 42 | } 43 | } 44 | } 45 | 46 | visibility_config { 47 | metric_name = "${local.prefix}-front-end" 48 | cloudwatch_metrics_enabled = true 49 | sampled_requests_enabled = true 50 | } 51 | 52 | rule { 53 | name = "ip-allow-list" 54 | priority = 100 55 | 56 | action { 57 | allow {} 58 | } 59 | 60 | statement { 61 | ip_set_reference_statement { 62 | arn = aws_wafv2_ip_set.ip_allow_list.arn 63 | } 64 | } 65 | 66 | visibility_config { 67 | cloudwatch_metrics_enabled = true 68 | metric_name = "AllowListIP" 69 | sampled_requests_enabled = true 70 | } 71 | } 72 | } 73 | 74 | locals { 75 | waf_front_end = { 76 | rules = [ 77 | { 78 | priority = 1 79 | name = "AWSManagedRulesCommonRuleSet" 80 | }, 81 | { 82 | priority = 2 83 | name = "AWSManagedRulesKnownBadInputsRuleSet" 84 | }, 85 | { 86 | priority = 3 87 | name = "AWSManagedRulesAmazonIpReputationList" 88 | }, 89 | { 90 | priority = 4 91 | name = "AWSManagedRulesLinuxRuleSet" 92 | }, 93 | { 94 | priority = 5 95 | name = "AWSManagedRulesUnixRuleSet" 96 | } 97 | ] 98 | } 99 | } 100 | 101 | resource "aws_wafv2_web_acl_logging_configuration" "front_end" { 102 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_us_east_1.arn] 103 | provider = aws.us_east_1 104 | resource_arn = aws_wafv2_web_acl.front_end.arn 105 | } 106 | -------------------------------------------------------------------------------- /terraform/20-app/ip-allow-lists.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | ip_allow_list = { 3 | engineers = [ 4 | "89.36.123.55/32", # Afaan 5 | "82.132.232.163/32", # Afaan 2 6 | "154.51.68.102/32", # Burendo Leeds 7 | "167.98.124.170/32", # Burendo London 8 | "90.219.251.228/32", # Phil 9 | "84.67.254.137/32", # Rhys 10 | "176.254.91.127/32", # Rhys 2 11 | "35.176.13.254/32", # UKHSA test EC2 12 | "35.176.178.91/32", # UKHSA test EC2 13 | "35.179.30.107/32", # UKHSA test EC2 14 | "18.133.111.70/32", # UKHSA test gateway 15 | "81.108.89.51/32", # Krishna - Macbook 16 | "165.225.197.26/32", # Krishna - Windows 17 | "80.7.227.61/32", # Kiran 18 | "92.234.44.48/32", # Zesh 19 | "51.241.222.137/32", # Temitope Akinsoto 20 | "86.177.34.133/32" # Luke 21 | ], 22 | project_team = [ 23 | "5.68.132.72/32", # Debbie 24 | ], 25 | other_stakeholders = [ 26 | "62.253.228.56/32", # UKHSA gateway 27 | "80.5.156.26/32", # Khawar 28 | "86.19.165.183/32", # Ehsan 29 | "90.196.35.64/32", # Kelly 30 | "86.159.135.80/32", # Asad 31 | "217.155.89.135/32", # Zoe Brass 32 | "18.135.62.168/32", # Load test rig 33 | "62.253.228.2/32", # Office ? / UKHSA ? / Asad 34 | "82.68.136.38/32", # Steve Ryan 35 | "90.208.183.134/32", # Christie 36 | "109.153.151.195/32", # Ciara 37 | "66.249.74.35/32", # Ciara 2 38 | "2.25.205.147/32", # Prince 39 | "86.128.102.66/32", # Ester 40 | "167.98.243.140/32", # Tom Hebbert 41 | "81.105.235.133/32", # Tom Hebbert 2 42 | "51.149.2.8/32", # Agostinho Sousa 43 | "136.226.191.87/32", # Charlotte Brace 44 | "2.221.74.175/32", # Gareth 45 | "81.108.143.100/32", # Ruairidh Villar 46 | "90.218.199.1/32", # Ruth Baxter 47 | "86.11.171.6/32", # Jason Deakin 48 | "192.168.0.20/32", # Alana Firth 49 | "62.253.228.56/32", # Georgina Milne 50 | ] 51 | ncc = [] 52 | } 53 | complete_ip_allow_list = tolist( 54 | # Cast back to a list for portability 55 | toset( 56 | # Cast the whole list to a set 57 | # to deduplicate any IP addresses 58 | # This should prevent duplicated IP addresses 59 | # from being included. 60 | # Which breaks the load balancers on deployments 61 | concat( 62 | # Combine all the sublists since the whole 63 | # list is used for access to the WAFs 64 | local.ip_allow_list.engineers, 65 | local.ip_allow_list.project_team, 66 | local.ip_allow_list.other_stakeholders, 67 | # Add NCC IP addresses only for the `pen` test environment 68 | local.environment == "pen" ? local.ip_allow_list.ncc : [] 69 | ) 70 | ) 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /terraform/20-app/outputs.tf: -------------------------------------------------------------------------------- 1 | output "ecs" { 2 | value = { 3 | cluster_name = module.ecs.cluster_name 4 | service_names = { 5 | cms_admin = module.ecs_service_cms_admin.name 6 | feedback_api = module.ecs_service_feedback_api.name 7 | private_api = module.ecs_service_private_api.name 8 | public_api = module.ecs_service_public_api.name 9 | front_end = module.ecs_service_front_end.name 10 | feature_flags = module.ecs_service_feature_flags.name 11 | } 12 | task_definitions = { 13 | cms_admin = module.ecs_service_cms_admin.task_definition_family 14 | feedback_api = module.ecs_service_feedback_api.task_definition_family 15 | private_api = module.ecs_service_private_api.task_definition_family 16 | public_api = module.ecs_service_public_api.task_definition_family 17 | front_end = module.ecs_service_front_end.task_definition_family 18 | } 19 | } 20 | } 21 | 22 | output "passwords" { 23 | value = { 24 | private_api_key = local.private_api_key 25 | cms_admin_user_password = random_password.cms_admin_user_password.result 26 | } 27 | sensitive = true 28 | } 29 | 30 | locals { 31 | urls = { 32 | archive = "https://${local.dns_names.archive}" 33 | cms_admin = "https://${local.dns_names.cms_admin}" 34 | feedback_api = "https://${local.dns_names.feedback_api}" 35 | front_end = "https://${local.dns_names.front_end}" 36 | front_end_lb = "https://${local.dns_names.front_end_lb}" 37 | legacy_dashboard = "https://${local.dns_names.legacy_dashboard}" 38 | private_api = "https://${local.dns_names.private_api}" 39 | public_api = "https://${local.dns_names.public_api}" 40 | public_api_lb = "https://${local.dns_names.public_api_lb}" 41 | feature_flags = "https://${local.dns_names.feature_flags}" 42 | } 43 | } 44 | 45 | output "urls" { 46 | value = local.urls 47 | } 48 | 49 | output "environment" { 50 | value = local.environment 51 | } 52 | 53 | output "cloud_front" { 54 | value = { 55 | front_end = module.cloudfront_front_end.cloudfront_distribution_id 56 | public_api = module.cloudfront_public_api.cloudfront_distribution_id 57 | } 58 | } 59 | 60 | output "s3" { 61 | value = { 62 | ingest_bucket_id = module.s3_ingest.s3_bucket_id 63 | } 64 | } 65 | 66 | output "ecr" { 67 | value = { 68 | repo_names = { 69 | ingestion = module.ecr_ingestion_lambda.repo_name 70 | back_end = module.ecr_back_end_ecs.repo_name 71 | front_end = module.ecr_front_end_ecs.repo_name 72 | 73 | } 74 | repo_urls = { 75 | ingestion = module.ecr_ingestion_lambda.repo_url 76 | back_end = module.ecr_back_end_ecs.repo_url 77 | front_end = module.ecr_front_end_ecs.repo_url 78 | } 79 | } 80 | } 81 | 82 | output "lambda" { 83 | value = { 84 | ingestion_lambda_arn = module.lambda_ingestion.lambda_function_arn 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /terraform/20-app/alb.front-end.tf: -------------------------------------------------------------------------------- 1 | module "front_end_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-front-end" 6 | 7 | load_balancer_type = "application" 8 | 9 | vpc_id = module.vpc.vpc_id 10 | subnets = module.vpc.public_subnets 11 | drop_invalid_header_fields = true 12 | enable_deletion_protection = false 13 | 14 | access_logs = { 15 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 16 | enabled = true 17 | prefix = "front-end-alb" 18 | } 19 | 20 | target_groups = { 21 | "${local.prefix}-front-end-tg" = { 22 | name = "${local.prefix}-front-end-tg" 23 | backend_protocol = "HTTP" 24 | backend_port = 3000 25 | target_type = "ip" 26 | create_attachment = false 27 | health_check = { 28 | enabled = true 29 | interval = 30 30 | path = "/api/health" 31 | port = "traffic-port" 32 | healthy_threshold = 3 33 | unhealthy_threshold = 3 34 | timeout = 5 35 | protocol = "HTTP" 36 | matcher = "200,404" 37 | } 38 | } 39 | } 40 | 41 | listeners = { 42 | "${local.prefix}-front-end-alb-listener" = { 43 | name = "${local.prefix}-front-end-alb-listener" 44 | port = 443 45 | protocol = "HTTPS" 46 | certificate_arn = local.certificate_arn 47 | ssl_policy = local.alb_security_policy 48 | fixed_response = { 49 | content_type = "text/plain" 50 | message_body = "403 Forbidden" 51 | status_code = "403" 52 | } 53 | rules = { 54 | enforce-header-value = { 55 | listener_key = "${local.prefix}-front-end-alb-listener" 56 | priority = 1 57 | actions = [ 58 | { 59 | type = "forward" 60 | target_group_key = "${local.prefix}-front-end-tg" 61 | } 62 | ] 63 | conditions = [ 64 | { 65 | http_header = { 66 | http_header_name = "x-cdn-auth" 67 | values = [ 68 | jsonencode(aws_secretsmanager_secret_version.cdn_front_end_secure_header_value.secret_string) 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | } 77 | 78 | security_group_ingress_rules = { 79 | ingress_from_internet = { 80 | from_port = 443 81 | to_port = 443 82 | ip_protocol = "tcp" 83 | cidr_ipv4 = "0.0.0.0/0" 84 | } 85 | } 86 | security_group_egress_rules = { 87 | egress_to_tasks = { 88 | ip_protocol = "tcp" 89 | from_port = 3000 90 | to_port = 3000 91 | referenced_security_group_id = module.ecs_service_front_end.security_group_id 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /terraform/20-app/alb.public-api.tf: -------------------------------------------------------------------------------- 1 | module "public_api_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-public-api" 6 | 7 | load_balancer_type = "application" 8 | 9 | vpc_id = module.vpc.vpc_id 10 | subnets = module.vpc.public_subnets 11 | drop_invalid_header_fields = true 12 | enable_deletion_protection = false 13 | 14 | access_logs = { 15 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 16 | enabled = true 17 | prefix = "public-api-alb" 18 | } 19 | 20 | target_groups = { 21 | "${local.prefix}-public-api-tg" = { 22 | name = "${local.prefix}-public-api-tg" 23 | backend_protocol = "HTTP" 24 | backend_port = 80 25 | target_type = "ip" 26 | create_attachment = false 27 | health_check = { 28 | enabled = true 29 | interval = 30 30 | path = "/health/" 31 | port = "traffic-port" 32 | healthy_threshold = 3 33 | unhealthy_threshold = 3 34 | timeout = 5 35 | protocol = "HTTP" 36 | matcher = "200" 37 | } 38 | } 39 | } 40 | 41 | listeners = { 42 | "${local.prefix}-public-api-alb-listener" = { 43 | name = "${local.prefix}-public-api-alb-listener" 44 | port = 443 45 | protocol = "HTTPS" 46 | certificate_arn = local.certificate_arn 47 | ssl_policy = local.alb_security_policy 48 | fixed_response = { 49 | content_type = "text/plain" 50 | message_body = "403 Forbidden" 51 | status_code = "403" 52 | } 53 | rules = { 54 | enforce-header-value = { 55 | listener_key = "${local.prefix}-public-api-alb-listener" 56 | priority = 1 57 | actions = [ 58 | { 59 | type = "forward" 60 | target_group_key = "${local.prefix}-public-api-tg" 61 | } 62 | ] 63 | conditions = [ 64 | { 65 | http_header = { 66 | http_header_name = "x-cdn-auth" 67 | values = [ 68 | jsonencode(aws_secretsmanager_secret_version.cdn_public_api_secure_header_value.secret_string) 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | } 77 | 78 | security_group_ingress_rules = { 79 | ingress_from_internet = { 80 | from_port = 443 81 | to_port = 443 82 | ip_protocol = "tcp" 83 | cidr_ipv4 = "0.0.0.0/0" 84 | } 85 | } 86 | security_group_egress_rules = { 87 | egress_to_tasks = { 88 | ip_protocol = "tcp" 89 | from_port = 80 90 | to_port = 80 91 | referenced_security_group_id = module.ecs_service_public_api.security_group_id 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /terraform/20-app/alb.private-api.tf: -------------------------------------------------------------------------------- 1 | module "private_api_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-private-api" 6 | 7 | load_balancer_type = "application" 8 | 9 | vpc_id = module.vpc.vpc_id 10 | subnets = module.vpc.public_subnets 11 | drop_invalid_header_fields = true 12 | enable_deletion_protection = false 13 | 14 | access_logs = { 15 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 16 | enabled = true 17 | prefix = "private-api-alb" 18 | } 19 | 20 | target_groups = { 21 | "${local.prefix}-private-api-tg" = { 22 | name = "${local.prefix}-private-api-tg" 23 | backend_protocol = "HTTP" 24 | backend_port = 80 25 | target_type = "ip" 26 | create_attachment = false 27 | health_check = { 28 | enabled = true 29 | interval = 30 30 | path = "/health/" 31 | port = "traffic-port" 32 | healthy_threshold = 3 33 | unhealthy_threshold = 3 34 | timeout = 5 35 | protocol = "HTTP" 36 | matcher = "200" 37 | } 38 | } 39 | } 40 | 41 | listeners = { 42 | "${local.prefix}-private-api-alb-listener" = { 43 | name = "${local.prefix}-private-api-alb-listener" 44 | port = 443 45 | protocol = "HTTPS" 46 | certificate_arn = local.certificate_arn 47 | ssl_policy = local.alb_security_policy 48 | fixed_response = { 49 | content_type = "application/json" 50 | message_body = jsonencode({ 51 | message = "Authentication credentials were not provided." 52 | }) 53 | status_code = "401" 54 | } 55 | rules = { 56 | enforce-api-key = { 57 | listener_key = "${local.prefix}-private-api-alb-listener" 58 | priority = 1 59 | actions = [ 60 | { 61 | type = "forward" 62 | target_group_key = "${local.prefix}-private-api-tg" 63 | } 64 | ] 65 | conditions = [ 66 | { 67 | http_header = { 68 | http_header_name = "Authorization" 69 | values = [aws_secretsmanager_secret_version.private_api_key.secret_string] 70 | } 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | } 77 | security_group_ingress_rules = { 78 | ingress_from_internet = { 79 | from_port = 443 80 | to_port = 443 81 | ip_protocol = "tcp" 82 | cidr_ipv4 = "0.0.0.0/0" 83 | } 84 | } 85 | security_group_egress_rules = { 86 | egress_to_tasks = { 87 | ip_protocol = "tcp" 88 | from_port = 80 89 | to_port = 80 90 | referenced_security_group_id = module.ecs_service_private_api.security_group_id 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /terraform/20-app/alb.feature-flags.tf: -------------------------------------------------------------------------------- 1 | module "feature_flags_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-feature-flags" 6 | 7 | load_balancer_type = "application" 8 | 9 | vpc_id = module.vpc.vpc_id 10 | subnets = module.vpc.public_subnets 11 | drop_invalid_header_fields = true 12 | enable_deletion_protection = false 13 | 14 | access_logs = { 15 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 16 | enabled = true 17 | prefix = "feature-flags-alb" 18 | } 19 | 20 | target_groups = { 21 | "${local.prefix}-feature-flags-tg" = { 22 | name = "${local.prefix}-feature-flags-tg" 23 | backend_protocol = "HTTP" 24 | backend_port = 4242 25 | target_type = "ip" 26 | create_attachment = false 27 | health_check = { 28 | enabled = true 29 | interval = 30 30 | path = "/health/" 31 | port = "traffic-port" 32 | healthy_threshold = 3 33 | unhealthy_threshold = 3 34 | timeout = 5 35 | protocol = "HTTP" 36 | matcher = "200" 37 | } 38 | } 39 | } 40 | 41 | listeners = { 42 | "${local.prefix}-feature-flags-alb-listener" = { 43 | name = "${local.prefix}-feature-flags-alb-listener" 44 | port = 443 45 | protocol = "HTTPS" 46 | certificate_arn = local.certificate_arn 47 | ssl_policy = local.alb_security_policy 48 | fixed_response = { 49 | content_type = "text/plain" 50 | message_body = "403 Forbidden" 51 | status_code = "403" 52 | } 53 | rules = { 54 | enforce-header-value = { 55 | listener_key = "${local.prefix}-feature-flags-alb-listener" 56 | priority = 1 57 | actions = [ 58 | { 59 | type = "forward" 60 | target_group_key = "${local.prefix}-feature-flags-tg" 61 | } 62 | ] 63 | conditions = [ 64 | { 65 | http_header = { 66 | http_header_name = "x-auth" 67 | values = [ 68 | jsondecode(aws_secretsmanager_secret_version.feature_flags_api_keys.secret_string)["x_auth"] 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | } 75 | } 76 | } 77 | 78 | security_group_ingress_rules = { 79 | ingress_from_internet = { 80 | from_port = 443 81 | to_port = 443 82 | ip_protocol = "tcp" 83 | cidr_ipv4 = "0.0.0.0/0" 84 | } 85 | } 86 | security_group_egress_rules = { 87 | egress_to_tasks = { 88 | ip_protocol = "tcp" 89 | from_port = 4242 90 | to_port = 4242 91 | referenced_security_group_id = module.ecs_service_feature_flags.security_group_id 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /terraform/20-app/s3.ingest.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | s3_ingest_bucket_name = "${local.prefix}-ingest" 3 | } 4 | 5 | module "s3_ingest" { 6 | source = "terraform-aws-modules/s3-bucket/aws" 7 | version = "4.1.2" 8 | 9 | bucket = local.s3_ingest_bucket_name 10 | 11 | attach_deny_insecure_transport_policy = true 12 | force_destroy = true 13 | 14 | logging = { 15 | target_bucket = data.aws_s3_bucket.s3_access_logs.id 16 | target_prefix = "${local.s3_ingest_bucket_name}/" 17 | } 18 | 19 | lifecycle_rule = [ 20 | { 21 | id = "processed" 22 | enabled = true 23 | filter = { 24 | prefix = "processed/" 25 | } 26 | expiration = { 27 | days = 30 28 | } 29 | }, 30 | { 31 | id = "failed" 32 | enabled = true 33 | filter = { 34 | prefix = "failed/" 35 | } 36 | expiration = { 37 | days = 14 38 | } 39 | }, 40 | { 41 | id = "stale" 42 | enabled = true 43 | filter = { 44 | prefix = "in/" 45 | } 46 | expiration = { 47 | days = 2 48 | } 49 | } 50 | ] 51 | 52 | attach_policy = true 53 | policy = jsonencode({ 54 | Version = "2012-10-17", 55 | Statement = concat([ 56 | { 57 | Sid = "OnlyAllowJsonFilesToTargetFolders", 58 | Effect = "Deny", 59 | Principal = "*", 60 | Action = ["s3:PutObject"], 61 | NotResource = [ 62 | "${module.s3_ingest.s3_bucket_arn}/in/", 63 | "${module.s3_ingest.s3_bucket_arn}/in/*.json", 64 | "${module.s3_ingest.s3_bucket_arn}/failed/", 65 | "${module.s3_ingest.s3_bucket_arn}/failed/*.json", 66 | "${module.s3_ingest.s3_bucket_arn}/processed/", 67 | "${module.s3_ingest.s3_bucket_arn}/processed/*.json", 68 | ] 69 | } 70 | ], 71 | local.is_ready_for_etl ? [ 72 | { 73 | Sid = "AllowCrossAccountAccessFromETLPublisherLambda", 74 | Effect = "Allow", 75 | Principal = { 76 | AWS = "arn:aws:iam::${local.etl_account_id}:role/${local.project}-etl-${local.environment}-publisher" 77 | } 78 | Action = ["s3:PutObject", "s3:ListBucket"] 79 | Resource = [ 80 | module.s3_ingest.s3_bucket_arn, 81 | "${module.s3_ingest.s3_bucket_arn}/*", 82 | ] 83 | } 84 | ] : []) 85 | }) 86 | } 87 | 88 | module "s3_ingest_notification" { 89 | source = "terraform-aws-modules/s3-bucket/aws//modules/notification" 90 | version = "4.1.2" 91 | 92 | bucket = module.s3_ingest.s3_bucket_id 93 | 94 | lambda_notifications = { 95 | lambda_ingestion = { 96 | function_arn = module.lambda_producer.lambda_function_arn 97 | function_name = module.lambda_producer.lambda_function_name 98 | events = ["s3:ObjectCreated:*"] 99 | filter_prefix = "in/" 100 | filter_suffix = ".json" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/public-api-cloud-front-viewer-request/index.test.js: -------------------------------------------------------------------------------- 1 | const originRequest = require("./index"); 2 | const handler = originRequest.__get__("handler"); 3 | 4 | test("Headers should not be removed", () => { 5 | const event = { 6 | request: { 7 | uri: "https://foo.bar/baz", 8 | querystring: {}, 9 | headers: { 10 | accept: { value: "text/html" }, 11 | "x-bar": { value: "baz" }, 12 | "x-foo": { value: "bar" }, 13 | }, 14 | }, 15 | }; 16 | 17 | const expected = { 18 | accept: { value: "text/html" }, 19 | "x-bar": { value: "baz" }, 20 | "x-foo": { value: "bar" }, 21 | }; 22 | 23 | const result = handler(event).headers; 24 | 25 | expect(result).toEqual(expected); 26 | }); 27 | 28 | test.each([ 29 | { accept: "text/html, foo/bar", expected: "text/html" }, 30 | { accept: "application/json, foo/bar", expected: "application/json" }, 31 | { 32 | accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 33 | expected: "text/html", 34 | }, 35 | { 36 | accept: "application/json,text/*;q=0.99", 37 | expected: "application/json", 38 | }, 39 | { 40 | accept: "", 41 | expected: "application/json", 42 | }, 43 | ])( 44 | "Accept header '$accept' should normalized to '$expected'", 45 | ({ accept, expected }) => { 46 | const event = { 47 | request: { 48 | querystring: {}, 49 | headers: { 50 | accept: { value: accept }, 51 | }, 52 | }, 53 | }; 54 | 55 | const result = handler(event); 56 | 57 | expect(result.headers.accept.value).toEqual(expected); 58 | } 59 | ); 60 | 61 | test.each([ 62 | "image/webp,image/avif,image/jxl,image/heic,image/heic-sequence,video/*;q=0.8,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5", 63 | "text/css,*/*;q=0.1", 64 | "*/*", 65 | ])( 66 | "Accept headers for images, css etc should be normalized to '' - test for '%s'", 67 | (accept) => { 68 | const event = { 69 | request: { 70 | querystring: {}, 71 | headers: { 72 | accept: { value: accept }, 73 | }, 74 | }, 75 | }; 76 | 77 | const result = handler(event); 78 | 79 | expect(result.headers.accept.value).toEqual(""); 80 | } 81 | ); 82 | 83 | test("When accept header is missing it defaults to application/json", () => { 84 | const event = { 85 | request: { 86 | querystring: {}, 87 | headers: { 88 | "x-bar": { value: "baz" }, 89 | "x-foo": { value: "bar" }, 90 | }, 91 | }, 92 | }; 93 | 94 | const result = handler(event); 95 | 96 | expect(result.headers.accept.value).toEqual("application/json"); 97 | }); 98 | 99 | test("When format=json query param is provided it defaults to application/json", () => { 100 | const event = { 101 | request: { 102 | querystring: {"format": {value: "json"}}, 103 | headers: { 104 | accept: { value: "text/plain" }, 105 | }, 106 | }, 107 | }; 108 | 109 | const result = handler(event); 110 | 111 | expect(result.headers.accept.value).toEqual("application/json"); 112 | }); 113 | -------------------------------------------------------------------------------- /terraform/20-app/alb.feedback-api.tf: -------------------------------------------------------------------------------- 1 | module "feedback_api_alb" { 2 | source = "terraform-aws-modules/alb/aws" 3 | version = "9.9.0" 4 | 5 | name = "${local.prefix}-feedback-api" 6 | 7 | load_balancer_type = "application" 8 | internal = true 9 | 10 | vpc_id = module.vpc.vpc_id 11 | subnets = module.vpc.public_subnets 12 | drop_invalid_header_fields = true 13 | enable_deletion_protection = false 14 | 15 | access_logs = { 16 | bucket = data.aws_s3_bucket.elb_logs_eu_west_2.id 17 | enabled = true 18 | prefix = "feedback-api-alb" 19 | } 20 | 21 | target_groups = { 22 | "${local.prefix}-feedback-api-tg" = { 23 | name = "${local.prefix}-feedback-api-tg" 24 | backend_protocol = "HTTP" 25 | backend_port = 80 26 | target_type = "ip" 27 | create_attachment = false 28 | health_check = { 29 | enabled = true 30 | interval = 30 31 | path = "/health/" 32 | port = "traffic-port" 33 | healthy_threshold = 3 34 | unhealthy_threshold = 3 35 | timeout = 5 36 | protocol = "HTTP" 37 | matcher = "200" 38 | } 39 | } 40 | } 41 | 42 | listeners = { 43 | "${local.prefix}-feedback-api-alb-listener" = { 44 | name = "${local.prefix}-feedback-api-alb-listener" 45 | port = 443 46 | protocol = "HTTPS" 47 | certificate_arn = local.certificate_arn 48 | ssl_policy = local.alb_security_policy 49 | fixed_response = { 50 | content_type = "application/json" 51 | message_body = jsonencode({ 52 | message = "Authentication credentials were not provided." 53 | }) 54 | status_code = "401" 55 | } 56 | rules = { 57 | enforce-api-key = { 58 | listener_key = "${local.prefix}-feedback-api-alb-listener" 59 | priority = 1 60 | actions = [ 61 | { 62 | type = "forward" 63 | target_group_key = "${local.prefix}-feedback-api-tg" 64 | } 65 | ] 66 | conditions = [ 67 | { 68 | http_header = { 69 | http_header_name = "Authorization" 70 | values = [aws_secretsmanager_secret_version.private_api_key.secret_string] 71 | } 72 | } 73 | ] 74 | } 75 | } 76 | } 77 | } 78 | 79 | security_group_ingress_rules = { 80 | ingress_from_front_end = { 81 | from_port = 443 82 | to_port = 443 83 | ip_protocol = "tcp" 84 | referenced_security_group_id = module.ecs_service_front_end.security_group_id 85 | } 86 | } 87 | security_group_egress_rules = { 88 | egress_to_tasks = { 89 | ip_protocol = "tcp" 90 | from_port = 80 91 | to_port = 80 92 | referenced_security_group_id = module.ecs_service_feedback_api.security_group_id 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /terraform/20-app/waf.cms.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "cms_admin" { 2 | name = "${local.prefix}-cms-admin" 3 | description = "Web ACL for CMS application" 4 | scope = "REGIONAL" 5 | 6 | default_action { 7 | allow {} 8 | } 9 | 10 | dynamic "rule" { 11 | for_each = local.waf_cms_admin.rules 12 | 13 | content { 14 | name = rule.value.name 15 | priority = rule.value.priority 16 | 17 | override_action { 18 | none {} 19 | } 20 | 21 | statement { 22 | managed_rule_group_statement { 23 | name = rule.value.name 24 | vendor_name = "AWS" 25 | } 26 | } 27 | 28 | visibility_config { 29 | metric_name = rule.value.name 30 | cloudwatch_metrics_enabled = true 31 | sampled_requests_enabled = true 32 | } 33 | } 34 | } 35 | 36 | visibility_config { 37 | metric_name = "${local.prefix}-cms" 38 | cloudwatch_metrics_enabled = true 39 | sampled_requests_enabled = true 40 | } 41 | 42 | rule { 43 | name = "AWSManagedRulesCommonRuleSet" 44 | priority = 1 45 | 46 | override_action { 47 | none {} 48 | } 49 | 50 | statement { 51 | managed_rule_group_statement { 52 | name = "AWSManagedRulesCommonRuleSet" 53 | vendor_name = "AWS" 54 | 55 | rule_action_override { 56 | action_to_use { 57 | allow {} 58 | } 59 | name = "SizeRestrictions_BODY" 60 | } 61 | } 62 | } 63 | 64 | visibility_config { 65 | metric_name = "AWSManagedRulesCommonRuleSet" 66 | cloudwatch_metrics_enabled = true 67 | sampled_requests_enabled = true 68 | } 69 | } 70 | 71 | rule { 72 | name = "rate-limiting" 73 | priority = 6 74 | 75 | action { 76 | block {} 77 | } 78 | statement { 79 | rate_based_statement { 80 | limit = 1000 81 | aggregate_key_type = "IP" 82 | } 83 | } 84 | visibility_config { 85 | cloudwatch_metrics_enabled = true 86 | metric_name = "RateLimitRule" 87 | sampled_requests_enabled = true 88 | } 89 | } 90 | } 91 | 92 | resource "aws_wafv2_web_acl_association" "cms_admin" { 93 | resource_arn = module.cms_admin_alb.arn 94 | web_acl_arn = aws_wafv2_web_acl.cms_admin.arn 95 | } 96 | 97 | locals { 98 | waf_cms_admin = { 99 | rules = [ 100 | { 101 | priority = 2 102 | name = "AWSManagedRulesKnownBadInputsRuleSet" 103 | }, 104 | { 105 | priority = 3 106 | name = "AWSManagedRulesAmazonIpReputationList" 107 | }, 108 | { 109 | priority = 4 110 | name = "AWSManagedRulesLinuxRuleSet" 111 | }, 112 | { 113 | priority = 5 114 | name = "AWSManagedRulesUnixRuleSet" 115 | } 116 | ] 117 | } 118 | } 119 | 120 | resource "aws_wafv2_web_acl_logging_configuration" "cms_admin" { 121 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_eu_west_2.arn] 122 | resource_arn = aws_wafv2_web_acl.cms_admin.arn 123 | } 124 | -------------------------------------------------------------------------------- /terraform/20-app/waf.public-api.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "public_api" { 2 | name = "${local.prefix}-public-api" 3 | description = "Web ACL for public api application" 4 | scope = "CLOUDFRONT" 5 | provider = aws.us_east_1 6 | 7 | default_action { 8 | dynamic "block" { 9 | for_each = local.use_ip_allow_list ? [""] : [] 10 | content { 11 | } 12 | } 13 | dynamic "allow" { 14 | for_each = local.use_ip_allow_list ? [] : [""] 15 | content { 16 | } 17 | } 18 | } 19 | 20 | dynamic "rule" { 21 | for_each = local.waf_public_api.rules 22 | 23 | content { 24 | name = rule.value.name 25 | priority = rule.value.priority 26 | 27 | override_action { 28 | none {} 29 | } 30 | 31 | statement { 32 | managed_rule_group_statement { 33 | name = rule.value.name 34 | vendor_name = "AWS" 35 | } 36 | } 37 | 38 | visibility_config { 39 | metric_name = rule.value.name 40 | cloudwatch_metrics_enabled = true 41 | sampled_requests_enabled = true 42 | } 43 | } 44 | } 45 | 46 | visibility_config { 47 | metric_name = "${local.prefix}-public-api" 48 | cloudwatch_metrics_enabled = true 49 | sampled_requests_enabled = true 50 | } 51 | 52 | rule { 53 | name = "ip-allow-list" 54 | priority = 6 55 | 56 | action { 57 | allow {} 58 | } 59 | 60 | statement { 61 | ip_set_reference_statement { 62 | arn = aws_wafv2_ip_set.ip_allow_list.arn 63 | } 64 | } 65 | 66 | visibility_config { 67 | cloudwatch_metrics_enabled = true 68 | metric_name = "AllowListIP" 69 | sampled_requests_enabled = true 70 | } 71 | } 72 | 73 | rule { 74 | name = "rate-limiting" 75 | priority = 7 76 | 77 | action { 78 | block {} 79 | } 80 | statement { 81 | rate_based_statement { 82 | limit = 2000 83 | aggregate_key_type = "IP" 84 | } 85 | } 86 | visibility_config { 87 | cloudwatch_metrics_enabled = true 88 | metric_name = "RateLimitRule" 89 | sampled_requests_enabled = true 90 | } 91 | } 92 | } 93 | 94 | locals { 95 | waf_public_api = { 96 | rules = [ 97 | { 98 | priority = 1 99 | name = "AWSManagedRulesCommonRuleSet" 100 | }, 101 | { 102 | priority = 2 103 | name = "AWSManagedRulesKnownBadInputsRuleSet" 104 | }, 105 | { 106 | priority = 3 107 | name = "AWSManagedRulesAmazonIpReputationList" 108 | }, 109 | { 110 | priority = 4 111 | name = "AWSManagedRulesLinuxRuleSet" 112 | }, 113 | { 114 | priority = 5 115 | name = "AWSManagedRulesUnixRuleSet" 116 | } 117 | ] 118 | } 119 | } 120 | 121 | resource "aws_wafv2_web_acl_logging_configuration" "public_api" { 122 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_us_east_1.arn] 123 | provider = aws.us_east_1 124 | resource_arn = aws_wafv2_web_acl.public_api.arn 125 | } 126 | -------------------------------------------------------------------------------- /.github/actions/configure-aws-credentials/action.yml: -------------------------------------------------------------------------------- 1 | name: "Configure AWS credentials" 2 | 3 | description: "Configures credentials for the selected AWS account" 4 | 5 | inputs: 6 | account-name: 7 | description: "The name of the AWS account being selected. Defaults to `tools`." 8 | default: "tools" 9 | aws-region: 10 | description: "The AWS region to configure credentials in." 11 | required: true 12 | role-duration-seconds: 13 | description: "The assumed role duration in seconds. Defaults to 1 hour." 14 | default: "3600" 15 | 16 | # Note that the roles are optional by default. 17 | # When using this composite action, you must pass in the role you need 18 | # from the `secrets` context. 19 | tools-account-role: 20 | description: "The role associated with the tools account" 21 | required: false 22 | prod-account-role: 23 | description: "The role associated with the prod account" 24 | required: false 25 | dev-account-role: 26 | description: "The role associated with the dev account" 27 | required: false 28 | test-account-role: 29 | description: "The role associated with the test account" 30 | required: false 31 | uat-account-role: 32 | description: "The role associated with the uat account" 33 | required: false 34 | 35 | runs: 36 | using: "composite" 37 | steps: 38 | - name: Configure AWS credentials for tools account 39 | uses: aws-actions/configure-aws-credentials@v4 40 | if: ${{ inputs.account-name == 'tools' }} 41 | with: 42 | role-to-assume: ${{ inputs.tools-account-role }} 43 | aws-region: ${{ inputs.aws-region }} 44 | role-duration-seconds: ${{ inputs.role-duration-seconds }} 45 | 46 | - name: Configure AWS credentials for prod account 47 | uses: aws-actions/configure-aws-credentials@v4 48 | if: ${{ inputs.account-name == 'prod' }} 49 | with: 50 | role-to-assume: ${{ inputs.prod-account-role }} 51 | aws-region: ${{ inputs.aws-region }} 52 | role-chaining: true 53 | role-duration-seconds: ${{ inputs.role-duration-seconds }} 54 | 55 | - name: Configure AWS credentials for dev account 56 | uses: aws-actions/configure-aws-credentials@v4 57 | if: ${{ inputs.account-name == 'dev' || inputs.account-name == 'dpd' }} 58 | with: 59 | role-to-assume: ${{ inputs.dev-account-role }} 60 | aws-region: ${{ inputs.aws-region }} 61 | role-chaining: true 62 | role-duration-seconds: ${{ inputs.role-duration-seconds }} 63 | 64 | - name: Configure AWS credentials for test account 65 | uses: aws-actions/configure-aws-credentials@v4 66 | if: ${{ inputs.account-name == 'test' }} 67 | with: 68 | role-to-assume: ${{ inputs.test-account-role }} 69 | aws-region: ${{ inputs.aws-region }} 70 | role-chaining: true 71 | role-duration-seconds: ${{ inputs.role-duration-seconds }} 72 | 73 | - name: Configure AWS credentials for uat account 74 | uses: aws-actions/configure-aws-credentials@v4 75 | if: ${{ inputs.account-name == 'uat' || inputs.account-name == 'staging' }} 76 | with: 77 | role-to-assume: ${{ inputs.uat-account-role }} 78 | aws-region: ${{ inputs.aws-region }} 79 | role-chaining: true 80 | role-duration-seconds: ${{ inputs.role-duration-seconds }} 81 | -------------------------------------------------------------------------------- /terraform/20-app/waf.feature-flags.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "feature_flags" { 2 | name = "${local.prefix}-feature-flags" 3 | description = "Web ACL for the feature flags application" 4 | scope = "REGIONAL" 5 | 6 | default_action { 7 | allow {} 8 | } 9 | 10 | dynamic "rule" { 11 | for_each = local.waf_feature_flags.rules 12 | 13 | content { 14 | name = rule.value.name 15 | priority = rule.value.priority 16 | 17 | override_action { 18 | none {} 19 | } 20 | 21 | statement { 22 | managed_rule_group_statement { 23 | name = rule.value.name 24 | vendor_name = "AWS" 25 | } 26 | } 27 | 28 | visibility_config { 29 | metric_name = rule.value.name 30 | cloudwatch_metrics_enabled = true 31 | sampled_requests_enabled = true 32 | } 33 | } 34 | } 35 | 36 | visibility_config { 37 | metric_name = "${local.prefix}-feature-flags" 38 | cloudwatch_metrics_enabled = true 39 | sampled_requests_enabled = true 40 | } 41 | 42 | rule { 43 | name = "AWSManagedRulesCommonRuleSet" 44 | priority = 1 45 | 46 | override_action { 47 | none {} 48 | } 49 | 50 | statement { 51 | managed_rule_group_statement { 52 | name = "AWSManagedRulesCommonRuleSet" 53 | vendor_name = "AWS" 54 | 55 | rule_action_override { 56 | action_to_use { 57 | allow {} 58 | } 59 | name = "SizeRestrictions_BODY" 60 | } 61 | } 62 | } 63 | 64 | visibility_config { 65 | metric_name = "AWSManagedRulesCommonRuleSet" 66 | cloudwatch_metrics_enabled = true 67 | sampled_requests_enabled = true 68 | } 69 | } 70 | 71 | rule { 72 | name = "rate-limiting" 73 | priority = 6 74 | 75 | action { 76 | block {} 77 | } 78 | statement { 79 | rate_based_statement { 80 | limit = 1000 81 | aggregate_key_type = "IP" 82 | } 83 | } 84 | visibility_config { 85 | cloudwatch_metrics_enabled = true 86 | metric_name = "RateLimitRule" 87 | sampled_requests_enabled = true 88 | } 89 | } 90 | } 91 | 92 | resource "aws_wafv2_web_acl_association" "feature-flags" { 93 | resource_arn = module.feature_flags_alb.arn 94 | web_acl_arn = aws_wafv2_web_acl.feature_flags.arn 95 | } 96 | 97 | locals { 98 | waf_feature_flags = { 99 | rules = [ 100 | { 101 | priority = 2 102 | name = "AWSManagedRulesKnownBadInputsRuleSet" 103 | }, 104 | { 105 | priority = 3 106 | name = "AWSManagedRulesAmazonIpReputationList" 107 | }, 108 | { 109 | priority = 4 110 | name = "AWSManagedRulesLinuxRuleSet" 111 | }, 112 | { 113 | priority = 5 114 | name = "AWSManagedRulesUnixRuleSet" 115 | } 116 | ] 117 | } 118 | } 119 | 120 | resource "aws_wafv2_web_acl_logging_configuration" "feature_flags" { 121 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_eu_west_2.arn] 122 | resource_arn = aws_wafv2_web_acl.feature_flags.arn 123 | } 124 | -------------------------------------------------------------------------------- /terraform/20-app/waf.archive-web-content.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "archive_web_content" { 2 | name = "${local.prefix}-archive-web-content" 3 | description = "Web ACL for archived web content" 4 | scope = "CLOUDFRONT" 5 | provider = aws.us_east_1 6 | 7 | default_action { 8 | dynamic "block" { 9 | for_each = local.use_ip_allow_list ? [""] : [] 10 | content { 11 | } 12 | } 13 | dynamic "allow" { 14 | for_each = local.use_ip_allow_list ? [] : [""] 15 | content { 16 | } 17 | } 18 | } 19 | 20 | dynamic "rule" { 21 | for_each = local.waf_archive_web_content.rules 22 | 23 | content { 24 | name = rule.value.name 25 | priority = rule.value.priority 26 | 27 | override_action { 28 | count {} 29 | } 30 | 31 | statement { 32 | managed_rule_group_statement { 33 | name = rule.value.name 34 | vendor_name = "AWS" 35 | } 36 | } 37 | 38 | visibility_config { 39 | metric_name = rule.value.name 40 | cloudwatch_metrics_enabled = true 41 | sampled_requests_enabled = true 42 | } 43 | } 44 | } 45 | 46 | visibility_config { 47 | metric_name = "${local.prefix}-archive-web-content" 48 | cloudwatch_metrics_enabled = true 49 | sampled_requests_enabled = true 50 | } 51 | 52 | rule { 53 | name = "ip-allow-list" 54 | priority = 100 55 | 56 | action { 57 | allow {} 58 | } 59 | 60 | statement { 61 | ip_set_reference_statement { 62 | arn = aws_wafv2_ip_set.ip_allow_list.arn 63 | } 64 | } 65 | 66 | visibility_config { 67 | cloudwatch_metrics_enabled = true 68 | metric_name = "AllowListIP" 69 | sampled_requests_enabled = true 70 | } 71 | } 72 | 73 | rule { 74 | name = "rate-limiting" 75 | priority = 6 76 | 77 | action { 78 | block {} 79 | } 80 | statement { 81 | rate_based_statement { 82 | limit = 1000 83 | aggregate_key_type = "IP" 84 | } 85 | } 86 | visibility_config { 87 | cloudwatch_metrics_enabled = true 88 | metric_name = "RateLimitRule" 89 | sampled_requests_enabled = true 90 | } 91 | } 92 | } 93 | 94 | locals { 95 | waf_archive_web_content = { 96 | rules = [ 97 | { 98 | priority = 1 99 | name = "AWSManagedRulesCommonRuleSet" 100 | }, 101 | { 102 | priority = 2 103 | name = "AWSManagedRulesKnownBadInputsRuleSet" 104 | }, 105 | { 106 | priority = 3 107 | name = "AWSManagedRulesAmazonIpReputationList" 108 | }, 109 | { 110 | priority = 4 111 | name = "AWSManagedRulesLinuxRuleSet" 112 | }, 113 | { 114 | priority = 5 115 | name = "AWSManagedRulesUnixRuleSet" 116 | } 117 | ] 118 | } 119 | } 120 | 121 | resource "aws_wafv2_web_acl_logging_configuration" "archive_web_content" { 122 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_us_east_1.arn] 123 | provider = aws.us_east_1 124 | resource_arn = aws_wafv2_web_acl.archive_web_content.arn 125 | } 126 | -------------------------------------------------------------------------------- /terraform/20-app/aurora-db.app.tf: -------------------------------------------------------------------------------- 1 | module "aurora_db_app" { 2 | source = "terraform-aws-modules/rds-aurora/aws" 3 | version = "9.5.0" 4 | 5 | name = "${local.prefix}-aurora-db-app" 6 | engine = "aurora-postgresql" 7 | engine_mode = "provisioned" 8 | engine_version = "15.5" 9 | storage_encrypted = true 10 | backup_retention_period = 35 11 | kms_key_id = module.kms_app_rds.key_arn 12 | 13 | manage_master_user_password = true 14 | database_name = "cms" 15 | master_username = "api_user" 16 | 17 | monitoring_interval = 0 18 | apply_immediately = true 19 | skip_final_snapshot = true 20 | publicly_accessible = local.enable_public_db 21 | deletion_protection = local.use_prod_sizing 22 | 23 | instance_class = "db.serverless" 24 | serverlessv2_scaling_configuration = { 25 | min_capacity = 1 26 | max_capacity = 50 27 | } 28 | instances = local.use_prod_sizing ? { 1 : {}, 2 : {}, 3 : {} } : { 1 : {} } 29 | 30 | vpc_id = module.vpc.vpc_id 31 | db_subnet_group_name = module.vpc.database_subnet_group_name 32 | 33 | security_group_rules = { 34 | # Ingress rules for connecting services 35 | private_api_tasks_to_db = { 36 | type = "ingress" 37 | description = "private api tasks to main db" 38 | protocol = "tcp" 39 | source_security_group_id = module.ecs_service_private_api.security_group_id 40 | }, 41 | public_api_tasks_to_db = { 42 | type = "ingress" 43 | description = "public api tasks to main db" 44 | protocol = "tcp" 45 | source_security_group_id = module.ecs_service_public_api.security_group_id 46 | }, 47 | cms_admin_tasks_to_db = { 48 | type = "ingress" 49 | description = "cms admin tasks to main db" 50 | protocol = "tcp" 51 | source_security_group_id = module.ecs_service_cms_admin.security_group_id 52 | }, 53 | feedback_api_tasks_to_db = { 54 | type = "ingress" 55 | description = "feedback api tasks to main db" 56 | protocol = "tcp" 57 | source_security_group_id = module.ecs_service_feedback_api.security_group_id 58 | }, 59 | utility_worker_tasks_to_db = { 60 | type = "ingress" 61 | description = "utility worker tasks to main db" 62 | protocol = "tcp" 63 | source_security_group_id = module.ecs_service_utility_worker.security_group_id 64 | }, 65 | ingestion_lambda_to_db = { 66 | type = "ingress" 67 | description = "ingestion lambda to main db" 68 | protocol = "tcp" 69 | source_security_group_id = module.lambda_ingestion_security_group.security_group_id 70 | }, 71 | } 72 | } 73 | 74 | locals { 75 | aurora = { 76 | app = { 77 | primary = { 78 | db_name = module.aurora_db_app.cluster_database_name 79 | address = module.aurora_db_app.cluster_endpoint 80 | } 81 | secondary = { 82 | db_name = module.aurora_db_app.cluster_database_name 83 | address = module.aurora_db_app.cluster_reader_endpoint 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /terraform/20-app/waf.legacy-dashboard-redirect.tf: -------------------------------------------------------------------------------- 1 | resource "aws_wafv2_web_acl" "legacy_dashboard_redirect" { 2 | name = "${local.prefix}-legacy-dashboard-redirect" 3 | description = "Web ACL for legacy dashboard redirect" 4 | scope = "CLOUDFRONT" 5 | provider = aws.us_east_1 6 | 7 | default_action { 8 | dynamic "block" { 9 | for_each = local.use_ip_allow_list ? [""] : [] 10 | content { 11 | } 12 | } 13 | dynamic "allow" { 14 | for_each = local.use_ip_allow_list ? [] : [""] 15 | content { 16 | } 17 | } 18 | } 19 | 20 | 21 | dynamic "rule" { 22 | for_each = local.waf_legacy_dashboard_redirect.rules 23 | 24 | content { 25 | name = rule.value.name 26 | priority = rule.value.priority 27 | 28 | override_action { 29 | count {} 30 | } 31 | 32 | statement { 33 | managed_rule_group_statement { 34 | name = rule.value.name 35 | vendor_name = "AWS" 36 | } 37 | } 38 | 39 | visibility_config { 40 | metric_name = rule.value.name 41 | cloudwatch_metrics_enabled = true 42 | sampled_requests_enabled = true 43 | } 44 | } 45 | } 46 | 47 | visibility_config { 48 | metric_name = "${local.prefix}-legacy-dashboard-redirect" 49 | cloudwatch_metrics_enabled = true 50 | sampled_requests_enabled = true 51 | } 52 | 53 | rule { 54 | name = "ip-allow-list" 55 | priority = 100 56 | 57 | action { 58 | allow {} 59 | } 60 | 61 | statement { 62 | ip_set_reference_statement { 63 | arn = aws_wafv2_ip_set.ip_allow_list.arn 64 | } 65 | } 66 | 67 | visibility_config { 68 | cloudwatch_metrics_enabled = true 69 | metric_name = "AllowListIP" 70 | sampled_requests_enabled = true 71 | } 72 | } 73 | 74 | rule { 75 | name = "rate-limiting" 76 | priority = 6 77 | 78 | action { 79 | block {} 80 | } 81 | statement { 82 | rate_based_statement { 83 | limit = 1000 84 | aggregate_key_type = "IP" 85 | } 86 | } 87 | visibility_config { 88 | cloudwatch_metrics_enabled = true 89 | metric_name = "RateLimitRule" 90 | sampled_requests_enabled = true 91 | } 92 | } 93 | 94 | } 95 | 96 | locals { 97 | waf_legacy_dashboard_redirect = { 98 | rules = [ 99 | { 100 | priority = 1 101 | name = "AWSManagedRulesCommonRuleSet" 102 | }, 103 | { 104 | priority = 2 105 | name = "AWSManagedRulesKnownBadInputsRuleSet" 106 | }, 107 | { 108 | priority = 3 109 | name = "AWSManagedRulesAmazonIpReputationList" 110 | }, 111 | { 112 | priority = 4 113 | name = "AWSManagedRulesLinuxRuleSet" 114 | }, 115 | { 116 | priority = 5 117 | name = "AWSManagedRulesUnixRuleSet" 118 | } 119 | ] 120 | } 121 | } 122 | 123 | resource "aws_wafv2_web_acl_logging_configuration" "legacy_dashboard_redirect" { 124 | log_destination_configs = [data.aws_s3_bucket.halo_waf_logs_us_east_1.arn] 125 | provider = aws.us_east_1 126 | resource_arn = aws_wafv2_web_acl.legacy_dashboard_redirect.arn 127 | } 128 | -------------------------------------------------------------------------------- /terraform/20-app/lambda.ingestion.tf: -------------------------------------------------------------------------------- 1 | module "lambda_ingestion" { 2 | source = "terraform-aws-modules/lambda/aws" 3 | version = "7.8.1" 4 | function_name = "${local.prefix}-ingestion" 5 | description = "Consumes records from the Kinesis data stream." 6 | 7 | vpc_subnet_ids = module.vpc.private_subnets 8 | vpc_security_group_ids = [module.lambda_ingestion_security_group.security_group_id] 9 | attach_network_policy = true 10 | 11 | cloudwatch_logs_retention_in_days = local.default_log_retention_in_days 12 | 13 | create_package = false 14 | package_type = "Image" 15 | architectures = ["arm64"] 16 | image_uri = module.ecr_ingestion_lambda.image_uri 17 | depends_on = [module.ecr_ingestion_lambda.repo_arn] 18 | 19 | maximum_retry_attempts = 1 20 | timeout = 60 # Timeout after 1 minute 21 | memory_size = 320 22 | 23 | event_source_mapping = { 24 | kinesis = { 25 | event_source_arn = aws_kinesis_stream.kinesis_data_stream_ingestion.arn 26 | starting_position = "LATEST" 27 | batch_size = 1 28 | retry_attempts = 1 29 | parallelization_factor = 10 30 | enabled = true 31 | } 32 | } 33 | 34 | environment_variables = { 35 | INGESTION_BUCKET_NAME = module.s3_ingest.s3_bucket_id 36 | POSTGRES_DB = module.aurora_db_app.cluster_database_name 37 | POSTGRES_HOST = module.aurora_db_app.cluster_endpoint 38 | POSTGRES_USER = module.aurora_db_app.cluster_master_username 39 | SECRETS_MANAGER_DB_CREDENTIALS_ARN = local.main_db_aurora_password_secret_arn 40 | APIENV = "PROD" 41 | APP_MODE = "INGESTION" 42 | } 43 | 44 | attach_policy_statements = true 45 | policy_statements = { 46 | move_items_from_in_folder_of_ingest_bucket = { 47 | actions = ["s3:GetObject", "s3:DeleteObject"] 48 | effect = "Allow" 49 | resources = ["${module.s3_ingest.s3_bucket_arn}/in/*"] 50 | } 51 | add_items_to_processed_folder_of_ingest_bucket = { 52 | actions = ["s3:PutObject"] 53 | effect = "Allow" 54 | resources = ["${module.s3_ingest.s3_bucket_arn}/processed/*"] 55 | } 56 | add_files_to_failed_folder_of_ingest_bucket = { 57 | actions = ["s3:PutObject"] 58 | effect = "Allow" 59 | resources = ["${module.s3_ingest.s3_bucket_arn}/failed/*"] 60 | } 61 | get_db_credentials_from_secrets_manager = { 62 | effect = "Allow", 63 | actions = ["secretsmanager:GetSecretValue"], 64 | resources = [local.main_db_aurora_password_secret_arn] 65 | } 66 | read_from_kinesis = { 67 | effect = "Allow" 68 | actions = [ 69 | "kinesis:GetRecords", 70 | "kinesis:GetShardIterator", 71 | "kinesis:ListStreams", 72 | "kinesis:ListShards", 73 | "kinesis:DescribeStream", 74 | "kinesis:DescribeStreamSummary", 75 | ] 76 | resources = [aws_kinesis_stream.kinesis_data_stream_ingestion.arn] 77 | } 78 | } 79 | } 80 | 81 | 82 | module "lambda_ingestion_security_group" { 83 | source = "terraform-aws-modules/security-group/aws" 84 | version = "5.1.0" 85 | 86 | name = "${local.prefix}-lambda-ingestion" 87 | vpc_id = module.vpc.vpc_id 88 | 89 | egress_with_cidr_blocks = [ 90 | { 91 | description = "https to internet" 92 | rule = "https-443-tcp" 93 | cidr_blocks = "0.0.0.0/0" 94 | } 95 | ] 96 | 97 | egress_with_source_security_group_id = [ 98 | { 99 | description = "ingestion lambda to aurora db" 100 | rule = "postgresql-tcp" 101 | source_security_group_id = module.aurora_db_app.security_group_id 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /terraform/20-app/cloud-front.archive-web-content.tf: -------------------------------------------------------------------------------- 1 | module "cloudfront_archive_web_content" { 2 | source = "terraform-aws-modules/cloudfront/aws" 3 | version = "3.4.0" 4 | 5 | aliases = [local.dns_names.archive] 6 | comment = "${local.prefix}-archive-web-content" 7 | create_origin_access_control = true 8 | default_root_object = "index.html" 9 | enabled = true 10 | wait_for_deployment = true 11 | web_acl_id = aws_wafv2_web_acl.archive_web_content.arn 12 | 13 | custom_error_response = [ 14 | { 15 | error_caching_min_ttl = 900 16 | error_code = 403 17 | response_code = 404 18 | response_page_path = "/errors/404.html" 19 | }, 20 | { 21 | error_caching_min_ttl = 900 22 | error_code = 404 23 | response_code = 404 24 | response_page_path = "/errors/404.html" 25 | } 26 | ] 27 | 28 | default_cache_behavior = { 29 | allowed_methods = ["HEAD", "GET", "OPTIONS"] 30 | cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized 31 | cached_methods = ["GET", "HEAD"] 32 | compress = true 33 | response_headers_policy_id = "eaab4381-ed33-4a86-88ca-d9558dc6cd63" # CORS-with-preflight-and-SecurityHeadersPolicy 34 | target_origin_id = "s3" 35 | use_forwarded_values = false 36 | viewer_protocol_policy = "redirect-to-https" 37 | } 38 | 39 | ordered_cache_behavior = [for ordered_cache_path in local.cloudfront_archive_web_content.ordered_cache_paths : { 40 | alowed_methods = ["HEAD", "GET", "OPTIONS"] 41 | cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized 42 | cached_methods = ["GET", "HEAD"] 43 | compress = true 44 | path_pattern = ordered_cache_path 45 | response_headers_policy_id = "eaab4381-ed33-4a86-88ca-d9558dc6cd63" # CORS-with-preflight-and-SecurityHeadersPolicy 46 | target_origin_id = "front_end" 47 | use_forwarded_values = false 48 | viewer_protocol_policy = "redirect-to-https" 49 | }] 50 | 51 | logging_config = { 52 | bucket = data.aws_s3_bucket.cloud_front_logs_eu_west_2.bucket_domain_name 53 | enabled = true 54 | include_cookies = false 55 | prefix = "${local.prefix}-archive-web-content" 56 | } 57 | 58 | origin_access_control = { 59 | "${local.prefix}-archive-web-content" = { 60 | description = "${local.prefix}-archive-web-content" 61 | origin_type = "s3" 62 | signing_behavior = "always" 63 | signing_protocol = "sigv4" 64 | } 65 | } 66 | 67 | origin = { 68 | s3 = { 69 | domain_name = module.s3_archive_web_content.s3_bucket_bucket_regional_domain_name 70 | origin_access_control = "${local.prefix}-archive-web-content" 71 | 72 | origin_shield = { 73 | enabled = true 74 | origin_shield_region = local.region 75 | } 76 | } 77 | 78 | front_end = { 79 | domain_name = "${local.dns_names.front_end}" 80 | 81 | custom_origin_config = { 82 | http_port = 80 83 | https_port = 443 84 | origin_protocol_policy = "https-only" 85 | origin_ssl_protocols = ["TLSv1.2"] 86 | } 87 | 88 | origin_shield = { 89 | enabled = true 90 | origin_shield_region = local.region 91 | } 92 | } 93 | } 94 | 95 | viewer_certificate = { 96 | acm_certificate_arn = local.cloud_front_certificate_arn 97 | ssl_support_method = "sni-only" 98 | minimum_protocol_version = "TLSv1.2_2021" 99 | } 100 | } 101 | 102 | locals { 103 | cloudfront_archive_web_content = { 104 | ordered_cache_paths = [ 105 | "/_next/*", 106 | "/assets/*", 107 | "/errors/*", 108 | ] 109 | } 110 | } 111 | --------------------------------------------------------------------------------