diff --git a/.checkov.baseline b/.checkov.baseline index da02a177e..09a28e1e2 100644 --- a/.checkov.baseline +++ b/.checkov.baseline @@ -27,7 +27,8 @@ { "resource": "module.extensions-staging.module.alb.aws_security_group.alb-security-group", "check_ids": [ - "CKV_AWS_23" + "CKV_AWS_23", + "CKV2_AWS_5" ] } ] @@ -337,4 +338,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index cc50a502d..ee59d5b77 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ For non-production: `internal..uktrade.digital` For production: `internal..prod.uktrade.digital` -Additional domains (`cdn_domains_list`) are the domain names that will be configured in CloudFront. In the map the key is the fully qualified domain name and the value is the application's base domain (the application's Route 53 zone). + If there are multiple web services on the application, you can add the additional domain to your certificate by adding the prefix name (eg. `internal.static`) to the variable `additional_address_list` see extension.yml example below. `Note: this is just the prefix, no need to add env.uktrade.digital` @@ -92,17 +92,48 @@ The R53 domains for non-production and production are stored in different AWS ac example `extensions.yml` config. +```yaml +my-application-alb: + type: alb + environments: + dev: + additional_address_list: + - internal.my-web-service-2 +``` + +## CDN + +This module will create the CloudFront (CDN) endpoints for the application if enabled. + +`cdn_domains_list` is a map of the domain names that will be configured in CloudFront. +* the key is the fully qualified domain name +* the value is an array containing the internal prefix and the base domain (the application's Route 53 zone). + +### Optional settings: + +To create a R53 record pointing to the CloudFront endpoint, set this to true. If not set, in non production this is set to true by default and set to false in production. +- enable_cdn_record: true + +To turn on CloudFront logging to a S3 bucket, set this to true. +- enable_logging: true + +example `extensions.yml` config. + ```yaml my-application-alb: type: alb environments: dev: cdn_domains_list: - dev.my-application.uktrade.digital: my-application.uktrade.digital + - dev.my-application.uktrade.digital: [ "internal", "my-application.uktrade.digital" ] + - dev.my-web-service-2.my-application.uktrade.digital: [ "internal.my-web-service-2", "my-application.uktrade.digital" ] additional_address_list: - internal.my-web-service-2 + enable_cdn_record: false + enable_logging: true prod: - domain: {my-application.great.gov.uk: "great.gov.uk"} + cdn_domains_list: + - my-application.prod.uktrade.digital: [ "internal", "my-application.prod.uktrade.digital" ] ``` ## Monitoring diff --git a/application-load-balancer/locals.tf b/application-load-balancer/locals.tf index 4fe72b9d1..5194fa5f0 100644 --- a/application-load-balancer/locals.tf +++ b/application-load-balancer/locals.tf @@ -30,7 +30,9 @@ locals { additional_address_fqdn = try({ for k in var.config.additional_address_list : "${k}.${local.additional_address_domain}" => "${var.application}.${local.domain_suffix}" }, {}) # A List of domains that can be used in the Subject Alternative Name (SAN) part of the certificate. - san_list = merge(local.additional_address_fqdn, var.config.cdn_domains_list) + # Only select the domain from the value field of cdn_domain_list (drop "internal") + culled_san_list = try({ for k, v in var.config.cdn_domains_list : "${k}" => "${v[1]}" }, {}) + san_list = merge(local.additional_address_fqdn, local.culled_san_list) # Create a complete domain list, primary domain plus all CDN/SAN domains. full_list = merge({ (local.domain_name) = "${var.application}.${local.domain_suffix}" }, local.san_list) diff --git a/application-load-balancer/tests/unit.tftest.hcl b/application-load-balancer/tests/unit.tftest.hcl index 726d86d2a..079106138 100644 --- a/application-load-balancer/tests/unit.tftest.hcl +++ b/application-load-balancer/tests/unit.tftest.hcl @@ -41,7 +41,7 @@ variables { vpc_name = "vpc-name" config = { domain_prefix = "dom-prefix", - cdn_domains_list = { "dev.my-application.uktrade.digital" : "my-application.uktrade.digital" }, + cdn_domains_list = { "dev.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital"] } } } diff --git a/application-load-balancer/variables.tf b/application-load-balancer/variables.tf index 7f9e6ab62..3bfcf0474 100644 --- a/application-load-balancer/variables.tf +++ b/application-load-balancer/variables.tf @@ -14,7 +14,7 @@ variable "config" { type = object({ domain_prefix = optional(string) env_root = optional(string) - cdn_domains_list = optional(map(string)) + cdn_domains_list = optional(map(list(string))) additional_address_list = optional(list(string)) }) } diff --git a/cdn/locals.tf b/cdn/locals.tf new file mode 100644 index 000000000..325ec1085 --- /dev/null +++ b/cdn/locals.tf @@ -0,0 +1,57 @@ +locals { + tags = { + application = var.application + environment = var.environment + managed-by = "DBT Platform - Terraform" + copilot-application = var.application + copilot-environment = var.environment + } + + # The primary domain for every application follows the naming standard documented under https://github.com/uktrade/terraform-platform-modules/blob/main/README.md#application-load-balancer-module + domain_suffix = var.environment == "prod" ? coalesce(var.config.env_root, "${var.application}.prod.uktrade.digital") : coalesce(var.config.env_root, "${var.environment}.${var.application}.uktrade.digital") + + cdn_domains_list = coalesce(var.config.cdn_domains_list, {}) + + # To avoid overwrites in prod we do not want to update R53 records by default. + enable_cdn_record = coalesce(var.config.enable_cdn_record, var.environment == "prod" ? false : true) + cdn_records = local.enable_cdn_record ? local.cdn_domains_list : {} + + # CDN logging buckets + logging_bucket = var.environment == "prod" ? "dbt-cloudfront-logs-prod.s3-eu-west-2.amazonaws.com" : "dbt-cloudfront-logs.s3-eu-west-2.amazonaws.com" + + # Default configuration for CDN. + cdn_defaults = { + viewer_protocol_policy = coalesce(var.config.viewer_protocol_policy, "redirect-to-https") + viewer_certificate = { + minimum_protocol_version = coalesce(var.config.viewer_certificate_minimum_protocol_version, "TLSv1.2_2021") + ssl_support_method = coalesce(var.config.viewer_certificate_ssl_support_method, "sni-only") + } + forwarded_values = { + query_string = coalesce(var.config.forwarded_values_query_string, true) + headers = coalesce(var.config.forwarded_values_headers, ["*"]) + cookies = { + forward = coalesce(var.config.forwarded_values_forward, "all") + } + } + allowed_methods = coalesce(var.config.allowed_methods, ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]) + cached_methods = coalesce(var.config.cached_methods, ["GET", "HEAD"]) + + origin = { + custom_origin_config = { + origin_protocol_policy = coalesce(var.config.origin_protocol_policy, "https-only") + origin_ssl_protocols = coalesce(var.config.origin_ssl_protocols, ["TLSv1.2"]) + } + } + compress = coalesce(var.config.cdn_compress, true) + + geo_restriction = { + restriction_type = coalesce(var.config.cdn_geo_restriction_type, "none") + locations = coalesce(var.config.cdn_geo_locations, []) + } + + # By default logging is off on all distros. + logging_config = coalesce(var.config.enable_logging, false) ? { bucket = local.logging_bucket } : {} + + default_waf = var.environment == "prod" ? coalesce(var.config.default_waf, "waf_sentinel_684092750218_default") : coalesce(var.config.default_waf, "waf_sentinel_011755346992_default") + } +} diff --git a/cdn/main.tf b/cdn/main.tf new file mode 100644 index 000000000..ceca7e254 --- /dev/null +++ b/cdn/main.tf @@ -0,0 +1,127 @@ +data "aws_wafv2_web_acl" "waf-default" { + provider = aws.domain-cdn + name = local.cdn_defaults.default_waf + scope = "CLOUDFRONT" +} + +resource "aws_acm_certificate" "certificate" { + provider = aws.domain-cdn + for_each = local.cdn_domains_list + + domain_name = each.key + validation_method = "DNS" + key_algorithm = "RSA_2048" + tags = local.tags + lifecycle { + create_before_destroy = true + } +} + +resource "aws_acm_certificate_validation" "cert-validate" { + provider = aws.domain-cdn + for_each = local.cdn_domains_list + certificate_arn = aws_acm_certificate.certificate[each.key].arn + validation_record_fqdns = [for record in aws_route53_record.validation-record : record.fqdn] +} + +data "aws_route53_zone" "domain-root" { + provider = aws.domain-cdn + for_each = local.cdn_domains_list + name = each.value[1] +} + +resource "aws_route53_record" "validation-record" { + provider = aws.domain-cdn + for_each = local.cdn_domains_list + zone_id = data.aws_route53_zone.domain-root[each.key].zone_id + name = tolist(aws_acm_certificate.certificate[each.key].domain_validation_options)[0].resource_record_name + type = tolist(aws_acm_certificate.certificate[each.key].domain_validation_options)[0].resource_record_type + records = [tolist(aws_acm_certificate.certificate[each.key].domain_validation_options)[0].resource_record_value] + ttl = 300 +} + +resource "aws_cloudfront_distribution" "standard" { + # checkov:skip=CKV_AWS_305:This is managed in the application. + # checkov:skip=CKV_AWS_310:No fail-over origin required. + # checkov:skip=CKV2_AWS_32:Response headers policy not required. + # checkov:skip=CKV2_AWS_47:WAFv2 WebACL rules are set in https://gitlab.ci.uktrade.digital/webops/terraform-waf + depends_on = [aws_acm_certificate_validation.cert-validate] + + provider = aws.domain-cdn + for_each = local.cdn_domains_list + enabled = true + is_ipv6_enabled = true + web_acl_id = data.aws_wafv2_web_acl.waf-default.arn + aliases = [each.key] + + origin { + domain_name = "${each.value[0]}.${local.domain_suffix}" + origin_id = "${each.value[0]}.${local.domain_suffix}" + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = local.cdn_defaults.origin.custom_origin_config.origin_protocol_policy + origin_ssl_protocols = local.cdn_defaults.origin.custom_origin_config.origin_ssl_protocols + } + } + + default_cache_behavior { + allowed_methods = local.cdn_defaults.allowed_methods + cached_methods = local.cdn_defaults.cached_methods + target_origin_id = "${each.value[0]}.${local.domain_suffix}" + forwarded_values { + query_string = local.cdn_defaults.forwarded_values.query_string + headers = local.cdn_defaults.forwarded_values.headers + cookies { + forward = local.cdn_defaults.forwarded_values.cookies.forward + } + } + compress = local.cdn_defaults.compress + viewer_protocol_policy = local.cdn_defaults.viewer_protocol_policy + min_ttl = 0 + default_ttl = 86400 + max_ttl = 31536000 + } + + viewer_certificate { + cloudfront_default_certificate = false + acm_certificate_arn = aws_acm_certificate.certificate[each.key].arn + minimum_protocol_version = local.cdn_defaults.viewer_certificate.minimum_protocol_version + ssl_support_method = local.cdn_defaults.viewer_certificate.ssl_support_method + } + + restrictions { + geo_restriction { + restriction_type = local.cdn_defaults.geo_restriction.restriction_type + locations = local.cdn_defaults.geo_restriction.locations + } + } + + dynamic "logging_config" { + for_each = local.cdn_defaults.logging_config + content { + bucket = local.cdn_defaults.logging_config.bucket + include_cookies = false + prefix = each.key + } + } + + tags = local.tags +} + +# This is only run if enable_cdn_record is set to true. +# Production default is false. +# Non prod this is true. +resource "aws_route53_record" "cdn-address" { + provider = aws.domain-cdn + + for_each = local.cdn_records + zone_id = data.aws_route53_zone.domain-root[each.key].zone_id + name = each.key + type = "A" + alias { + name = aws_cloudfront_distribution.standard[each.key].domain_name + zone_id = aws_cloudfront_distribution.standard[each.key].hosted_zone_id + evaluate_target_health = false + } +} diff --git a/cdn/providers.tf b/cdn/providers.tf new file mode 100644 index 000000000..c5874a067 --- /dev/null +++ b/cdn/providers.tf @@ -0,0 +1,12 @@ +terraform { + required_version = "~> 1.7" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5" + configuration_aliases = [ + aws.domain-cdn, + ] + } + } +} diff --git a/cdn/tests/unit.tftest.hcl b/cdn/tests/unit.tftest.hcl new file mode 100644 index 000000000..b8b13f1c0 --- /dev/null +++ b/cdn/tests/unit.tftest.hcl @@ -0,0 +1,56 @@ +mock_provider "aws" { + alias = "domain-cdn" +} + +mock_provider "aws" { + alias = "domain" +} + +override_data { + target = data.aws_route53_zone.domain-root + values = { + count = 0 + name = "my-application.uktrade.digital" + } +} + +variables { + application = "app" + environment = "env" + vpc_name = "vpc-name" + config = { + domain_prefix = "dom-prefix", + cdn_domains_list = { "dev.my-application.uktrade.digital" : ["internal", "my-application.uktrade.digital"] } + } +} + + +run "aws_route53_record_unit_test" { + command = plan + + assert { + condition = aws_route53_record.cdn-address["dev.my-application.uktrade.digital"].name == "dev.my-application.uktrade.digital" + error_message = "Should be: dev.my-application.uktrade.digital" + } + +} + +run "aws_acm_certificate_unit_test" { + command = plan + + assert { + condition = aws_acm_certificate.certificate["dev.my-application.uktrade.digital"].domain_name == "dev.my-application.uktrade.digital" + error_message = "Should be: dev.my-application.uktrade.digital" + } + +} + +run "aws_cloudfront_distribution_unit_test" { + command = plan + + assert { + condition = [for k in aws_cloudfront_distribution.standard["dev.my-application.uktrade.digital"].aliases : true if k == "dev.my-application.uktrade.digital"][0] == true + error_message = "Should be: [ dev.my-application.uktrade.digital, ]" + } + +} diff --git a/cdn/variables.tf b/cdn/variables.tf new file mode 100644 index 000000000..d4743b06c --- /dev/null +++ b/cdn/variables.tf @@ -0,0 +1,40 @@ +variable "application" { + type = string +} + +variable "environment" { + type = string +} + +variable "vpc_name" { + type = string +} + +variable "config" { + type = object({ + domain_prefix = optional(string) + env_root = optional(string) + cdn_domains_list = optional(map(list(string))) + additional_address_list = optional(list(string)) + enable_cdn_record = optional(bool) + enable_logging = optional(bool) + + # CDN default overrides + viewer_certificate_minimum_protocol_version = optional(string) + viewer_certificate_ssl_support_method = optional(string) + forwarded_values_query_string = optional(bool) + forwarded_values_headers = optional(list(string)) + forwarded_values_forward = optional(string) + viewer_protocol_policy = optional(string) + allowed_methods = optional(list(string)) + cached_methods = optional(list(string)) + default_waf = optional(string) + origin_protocol_policy = optional(string) + origin_ssl_protocols = optional(list(string)) + cdn_compress = optional(bool) + cdn_geo_restriction_type = optional(string) + cdn_geo_locations = optional(list(string)) + cdn_logging_bucket = optional(string) + cdn_logging_bucket_prefix = optional(string) + }) +} diff --git a/extensions/locals.tf b/extensions/locals.tf index f97ca70dd..6922beb41 100644 --- a/extensions/locals.tf +++ b/extensions/locals.tf @@ -22,6 +22,7 @@ locals { opensearch = { for k, v in local.services : k => v if v.type == "opensearch" } monitoring = { for k, v in local.services : k => v if v.type == "monitoring" } alb = { for k, v in local.services : k => v if v.type == "alb" } + cdn = { for k, v in local.services : k => v if v.type == "alb" } tags = { application = var.args.application diff --git a/extensions/main.tf b/extensions/main.tf index 49d9c1d9d..02c7392c1 100644 --- a/extensions/main.tf +++ b/extensions/main.tf @@ -64,6 +64,20 @@ module "alb" { config = each.value } +module "cdn" { + source = "../cdn" + + for_each = local.cdn + providers = { + aws.domain-cdn = aws.domain-cdn + } + application = var.args.application + environment = var.environment + vpc_name = var.vpc_name + + config = each.value +} + module "monitoring" { source = "../monitoring" diff --git a/extensions/plans.yml b/extensions/plans.yml index 3ae84caf6..6f0702f3f 100644 --- a/extensions/plans.yml +++ b/extensions/plans.yml @@ -220,3 +220,5 @@ s3-policy: {} monitoring: {} alb: {} + +cdn: {} diff --git a/extensions/providers.tf b/extensions/providers.tf index fc5cb7f0c..1558721c2 100644 --- a/extensions/providers.tf +++ b/extensions/providers.tf @@ -5,6 +5,14 @@ provider "aws" { } } +provider "aws" { + region = "us-east-1" + alias = "domain-cdn" + assume_role { + role_arn = "arn:aws:iam::${var.args.dns_account_id}:role/environment-pipeline-assumed-role" + } +} + terraform { required_version = "~> 1.7" required_providers { @@ -13,6 +21,7 @@ terraform { version = "~> 5" configuration_aliases = [ aws.domain, + aws.domain-cdn ] } } diff --git a/extensions/tests/unit.tftest.hcl b/extensions/tests/unit.tftest.hcl index 874f5a12d..b268a8462 100644 --- a/extensions/tests/unit.tftest.hcl +++ b/extensions/tests/unit.tftest.hcl @@ -39,6 +39,10 @@ mock_provider "aws" { alias = "domain" } +mock_provider "aws" { + alias = "domain-cdn" +} + override_data { target = module.opensearch["test-opensearch"].data.aws_vpc.vpc values = {