diff --git a/infra/frontend/service/main.tf b/infra/frontend/service/main.tf index 6ee30d1b6..d4ffdb5be 100644 --- a/infra/frontend/service/main.tf +++ b/infra/frontend/service/main.tf @@ -123,6 +123,7 @@ module "service" { public_subnet_ids = data.aws_subnets.public.ids private_subnet_ids = data.aws_subnets.private.ids cert_arn = local.domain != null ? data.aws_acm_certificate.cert[0].arn : null + domain = local.domain hostname = module.app_config.hostname desired_instance_count = local.service_config.instance_desired_instance_count max_capacity = local.service_config.instance_scaling_max_capacity @@ -130,6 +131,7 @@ module "service" { enable_autoscaling = true cpu = local.service_config.instance_cpu memory = local.service_config.instance_memory + enable_cdn = true app_access_policy_arn = null migrator_access_policy_arn = null diff --git a/infra/frontend/service/outputs.tf b/infra/frontend/service/outputs.tf index fe319eabf..9cc2951a2 100644 --- a/infra/frontend/service/outputs.tf +++ b/infra/frontend/service/outputs.tf @@ -3,6 +3,11 @@ output "service_endpoint" { value = module.service.public_endpoint } +output "cdn_endpoint" { + description = "The CDN endpoint for the service." + value = module.service.cdn_endpoint +} + output "service_cluster_name" { value = module.service.cluster_name } diff --git a/infra/modules/service/cdn-logs.tf b/infra/modules/service/cdn-logs.tf new file mode 100644 index 000000000..fe67863c5 --- /dev/null +++ b/infra/modules/service/cdn-logs.tf @@ -0,0 +1,86 @@ +resource "aws_s3_bucket" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket_prefix = "${var.service_name}-cdn-access-logs" + force_destroy = false + # checkov:skip=CKV2_AWS_62:Event notification not necessary for this bucket especially due to likely use of lifecycle rules + # checkov:skip=CKV_AWS_18:Access logging was not considered necessary for this bucket + # checkov:skip=CKV_AWS_144:Not considered critical to the point of cross region replication + # checkov:skip=CKV_AWS_300:Known issue where Checkov gets confused by multiple rules + # checkov:skip=CKV_AWS_21:Bucket versioning is not worth it in this use case + # checkov:skip=CKV_AWS_145:Use KMS in future work + # checkov:skip=CKV2_AWS_65:We need ACLs for Cloudfront +} + +resource "aws_s3_bucket_ownership_controls" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket = aws_s3_bucket.cdn[0].id + rule { + object_ownership = "BucketOwnerPreferred" + } + # checkov:skip=CKV2_AWS_65:We need ACLs for Cloudfront +} + +resource "aws_s3_bucket_acl" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket = aws_s3_bucket.cdn[0].id + + acl = "log-delivery-write" + + depends_on = [aws_s3_bucket_ownership_controls.cdn[0]] +} + +resource "aws_s3_bucket_public_access_block" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket = aws_s3_bucket.cdn[0].id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +data "aws_iam_policy_document" "cdn" { + count = var.enable_cdn ? 1 : 0 + + statement { + actions = [ + "s3:GetObject", + ] + + resources = [ + "${aws_s3_bucket.cdn[0].arn}/*", + ] + + principals { + type = "AWS" + identifiers = [aws_cloudfront_origin_access_identity.cdn[0].iam_arn] + } + } +} + +resource "aws_s3_bucket_policy" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket = aws_s3_bucket.cdn[0].id + policy = data.aws_iam_policy_document.cdn[0].json +} + +resource "aws_s3_bucket_lifecycle_configuration" "cdn" { + count = var.enable_cdn ? 1 : 0 + + bucket = aws_s3_bucket.cdn[0].id + + rule { + id = "AbortIncompleteUpload" + status = "Enabled" + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + # checkov:skip=CKV_AWS_300:There is a known issue where this check brings up false positives +} diff --git a/infra/modules/service/cdn.tf b/infra/modules/service/cdn.tf new file mode 100644 index 000000000..6297b77b5 --- /dev/null +++ b/infra/modules/service/cdn.tf @@ -0,0 +1,111 @@ +locals { + # We have the option to route the CDN to multiple origins based on the origin id. + # We do not currently do this, though. + default_origin_id = "default" +} + +resource "aws_cloudfront_origin_access_identity" "cdn" { + count = var.enable_cdn ? 1 : 0 + + comment = "Origin Access Identity for CloudFront to access S3 bucket" +} + +resource "aws_cloudfront_cache_policy" "default" { + count = var.enable_cdn ? 1 : 0 + + name = "default" + + # Default to caching for 1 hour. + # The default TTL can be overriden by the `Cache-Control max-age` or `Expires` headers + # There's also a `max_ttl` option, which can be used to override the above headers. + min_ttl = 0 + default_ttl = 3600 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "all" + } + headers_config { + # The only options are "none" and "whitelist", there is no "all" option + header_behavior = "none" + } + query_strings_config { + query_string_behavior = "all" + } + } +} + +resource "aws_cloudfront_distribution" "cdn" { + count = var.enable_cdn ? 1 : 0 + + enabled = var.enable_cdn ? true : false + aliases = var.domain == null ? null : [var.domain] + default_root_object = "/" + + origin { + domain_name = aws_lb.alb[0].dns_name + origin_id = local.default_origin_id + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = var.cert_arn == null ? "http-only" : "https-only" + + # See possible values here: + # https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_OriginSslProtocols.html + origin_ssl_protocols = ["TLSv1.2"] + } + # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/origin-shield.html + dynamic "origin_shield" { + for_each = var.cert_arn == null ? [1] : [] + content { + enabled = true + origin_shield_region = data.aws_region.current.name + } + } + } + + logging_config { + include_cookies = false + bucket = aws_s3_bucket.cdn[0].bucket_domain_name + } + + default_cache_behavior { + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + target_origin_id = local.default_origin_id + cache_policy_id = aws_cloudfront_cache_policy.default[0].id + compress = true + viewer_protocol_policy = var.cert_arn == null ? "allow-all" : "redirect-to-https" + + # Default to caching for 1 hour. + # The default TTL can be overriden by the `Cache-Control max-age` or `Expires` headers + # There's also a `max_ttl` option, which can be used to override the above headers. + min_ttl = 0 + default_ttl = 3600 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + acm_certificate_arn = var.cert_arn == null ? null : var.cert_arn + cloudfront_default_certificate = var.cert_arn == null ? true : false + } + + depends_on = [ + aws_s3_bucket_public_access_block.cdn[0], + aws_s3_bucket_policy.cdn[0], + aws_s3_bucket.cdn[0], + ] + + #checkov:skip=CKV2_AWS_46:We aren't using a S3 origin + #checkov:skip=CKV_AWS_174:False positive + #checkov:skip=CKV_AWS_310:Configure a failover in future work + #checkov:skip=CKV_AWS_68:Configure WAF in future work + #checkov:skip=CKV2_AWS_47:Configure WAF in future work + #checkov:skip=CKV2_AWS_32:Configure response headers policy in future work + #checkov:skip=CKV_AWS_374:Ignore the geo restriction +} diff --git a/infra/modules/service/outputs.tf b/infra/modules/service/outputs.tf index 6cfd22ea3..1ba1e5599 100644 --- a/infra/modules/service/outputs.tf +++ b/infra/modules/service/outputs.tf @@ -3,6 +3,11 @@ output "public_endpoint" { value = var.enable_load_balancer ? "http://${aws_lb.alb[0].dns_name}" : null } +output "cdn_endpoint" { + description = "The CDN endpoint for the service." + value = var.enable_cdn ? aws_cloudfront_distribution.cdn[0].domain_name : null +} + output "cluster_name" { value = aws_ecs_cluster.cluster.name } diff --git a/infra/modules/service/variables.tf b/infra/modules/service/variables.tf index b635a17a0..3efca8f96 100644 --- a/infra/modules/service/variables.tf +++ b/infra/modules/service/variables.tf @@ -162,6 +162,12 @@ variable "readonly_root_filesystem" { default = true } +variable "domain" { + description = "The domain name for the service" + type = string + default = null +} + variable "drop_linux_capabilities" { description = "Whether to drop linux parameters" type = bool @@ -174,6 +180,12 @@ variable "enable_load_balancer" { default = true } +variable "enable_cdn" { + description = "Whether to enable a CDN for the service" + type = bool + default = false +} + variable "healthcheck_command" { description = "The command to run to check the health of the container, used on the container health check" type = list(string)