diff --git a/infrastructure/terraform/components/acct/versions.tf b/infrastructure/terraform/components/acct/versions.tf index 5fba18d2..1dbd936a 100644 --- a/infrastructure/terraform/components/acct/versions.tf +++ b/infrastructure/terraform/components/acct/versions.tf @@ -6,5 +6,5 @@ terraform { } } - required_version = ">= 1.9.0" + required_version = ">= 1.9.2" } diff --git a/infrastructure/terraform/components/app/module_public_signing_keys.tf b/infrastructure/terraform/components/app/module_public_signing_keys.tf new file mode 100644 index 00000000..8a721b31 --- /dev/null +++ b/infrastructure/terraform/components/app/module_public_signing_keys.tf @@ -0,0 +1,9 @@ +module "public_signing_keys" { + source = "../../modules/public-signing-keys" + aws_account_id = var.aws_account_id + environment = var.environment + region = var.region + project = var.project + csi = local.csi + acct = local.acct +} diff --git a/infrastructure/terraform/components/app/versions.tf b/infrastructure/terraform/components/app/versions.tf index 5fba18d2..e7d6a6a0 100644 --- a/infrastructure/terraform/components/app/versions.tf +++ b/infrastructure/terraform/components/app/versions.tf @@ -4,7 +4,11 @@ terraform { source = "hashicorp/aws" version = "~> 5.50" } + github = { + source = "integrations/github" + version = "~> 6.0" + } } - required_version = ">= 1.9.0" + required_version = ">= 1.9.2" } diff --git a/infrastructure/terraform/components/branch/versions.tf b/infrastructure/terraform/components/branch/versions.tf index 5fba18d2..1dbd936a 100644 --- a/infrastructure/terraform/components/branch/versions.tf +++ b/infrastructure/terraform/components/branch/versions.tf @@ -6,5 +6,5 @@ terraform { } } - required_version = ">= 1.9.0" + required_version = ">= 1.9.2" } diff --git a/infrastructure/terraform/components/sandbox/locals_remote_state.tf b/infrastructure/terraform/components/sandbox/locals_remote_state.tf new file mode 100644 index 00000000..50c57af6 --- /dev/null +++ b/infrastructure/terraform/components/sandbox/locals_remote_state.tf @@ -0,0 +1,40 @@ +locals { + bootstrap = data.terraform_remote_state.bootstrap.outputs + acct = data.terraform_remote_state.acct.outputs +} + +data "terraform_remote_state" "bootstrap" { + backend = "s3" + + config = { + bucket = local.terraform_state_bucket + + key = format( + "%s/%s/%s/%s/bootstrap.tfstate", + var.project, + var.aws_account_id, + "eu-west-2", + "bootstrap" + ) + + region = "eu-west-2" + } +} + +data "terraform_remote_state" "acct" { + backend = "s3" + + config = { + bucket = local.terraform_state_bucket + + key = format( + "%s/%s/%s/%s/acct.tfstate", + var.project, + var.aws_account_id, + "eu-west-2", + "main" + ) + + region = "eu-west-2" + } +} diff --git a/infrastructure/terraform/components/sandbox/module_public_signing_keys.tf b/infrastructure/terraform/components/sandbox/module_public_signing_keys.tf new file mode 100644 index 00000000..8a721b31 --- /dev/null +++ b/infrastructure/terraform/components/sandbox/module_public_signing_keys.tf @@ -0,0 +1,9 @@ +module "public_signing_keys" { + source = "../../modules/public-signing-keys" + aws_account_id = var.aws_account_id + environment = var.environment + region = var.region + project = var.project + csi = local.csi + acct = local.acct +} diff --git a/infrastructure/terraform/components/sandbox/versions.tf b/infrastructure/terraform/components/sandbox/versions.tf new file mode 100644 index 00000000..e7d6a6a0 --- /dev/null +++ b/infrastructure/terraform/components/sandbox/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.50" + } + github = { + source = "integrations/github" + version = "~> 6.0" + } + } + + required_version = ">= 1.9.2" +} diff --git a/infrastructure/terraform/modules/public-signing-keys/cloudfront_distribution_public_signing_keys.tf b/infrastructure/terraform/modules/public-signing-keys/cloudfront_distribution_public_signing_keys.tf new file mode 100644 index 00000000..7627dbb5 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/cloudfront_distribution_public_signing_keys.tf @@ -0,0 +1,80 @@ +resource "aws_cloudfront_distribution" "signing_keys" { + provider = aws.us-east-1 + + enabled = true + is_ipv6_enabled = true + comment = "Public Signing Keys (${local.csi})" + default_root_object = "index.html" + price_class = "PriceClass_100" # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudfront-distribution-distributionconfig.html#cfn-cloudfront-distribution-distributionconfig-priceclass + web_acl_id = aws_wafv2_web_acl.public_signing_keys.arn + + restrictions { + geo_restriction { + restriction_type = "none" # Moved to WAF + locations = [] # Moved to WAF + } + } + + # TODO + # aliases = flatten([ + # [ + # local.root_domain_name, + # ], + # var.cdn_sans + # ]) + + # TODO + # viewer_certificate { + # acm_certificate_arn = aws_acm_certificate.main.arn + # minimum_protocol_version = "TLSv1.2_2021" # Supports 1.2 & 1.3 - https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html + # ssl_support_method = "sni-only" + # } + viewer_certificate { + cloudfront_default_certificate = true + } + + logging_config { + bucket = module.s3bucket_cf_logs.bucket_regional_domain_name + include_cookies = false + } + + origin { + domain_name = module.s3bucket_public_keys.bucket_regional_domain_name + origin_id = "${local.csi}-public-keys" + s3_origin_config { + origin_access_identity = aws_cloudfront_origin_access_identity.signing_keys.cloudfront_access_identity_path + } + } + + # Github Web-CMS behaviour + default_cache_behavior { + allowed_methods = [ + "GET", + "HEAD", + ] + cached_methods = [ + "GET", + "HEAD", + ] + target_origin_id = "${local.csi}-public-keys" + + forwarded_values { + query_string = false + headers = ["Origin"] + + cookies { + forward = "none" + } + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 3600 + max_ttl = 86400 + compress = true + } +} + +resource "aws_cloudfront_origin_access_identity" "signing_keys" { + comment = "Used to access the S3 content for the public signing keys bucket" +} diff --git a/infrastructure/terraform/modules/public-signing-keys/github_ip_ranges.tf b/infrastructure/terraform/modules/public-signing-keys/github_ip_ranges.tf new file mode 100644 index 00000000..0540d96e --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/github_ip_ranges.tf @@ -0,0 +1 @@ +data "github_ip_ranges" "main" {} diff --git a/infrastructure/terraform/modules/public-signing-keys/iam_policy_s3_public_signing_keys.tf b/infrastructure/terraform/modules/public-signing-keys/iam_policy_s3_public_signing_keys.tf new file mode 100644 index 00000000..17e42be7 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/iam_policy_s3_public_signing_keys.tf @@ -0,0 +1,23 @@ +resource "aws_iam_policy" "public_signing_keys" { + name = "${local.csi}-public-signing-keys" + description = "Access policy to allow access to public signing keys in S3" + path = "/" + policy = data.aws_iam_policy_document.public_signing_keys.json +} + +data "aws_iam_policy_document" "public_signing_keys" { + statement { + sid = "AllowS3Read" + effect = "Allow" + + actions = [ + "s3:List*", + "s3:Get*", + ] + + resources = [ + module.s3bucket_public_keys.arn, + "${module.s3bucket_public_keys.arn}/*", + ] + } +} diff --git a/infrastructure/terraform/modules/public-signing-keys/locals.tf b/infrastructure/terraform/modules/public-signing-keys/locals.tf new file mode 100644 index 00000000..5e50b64f --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/locals.tf @@ -0,0 +1,3 @@ +locals { + csi = "${var.csi}-${var.component}" +} diff --git a/infrastructure/terraform/modules/public-signing-keys/module_s3bucket_cf_logs.tf b/infrastructure/terraform/modules/public-signing-keys/module_s3bucket_cf_logs.tf new file mode 100644 index 00000000..403bc547 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/module_s3bucket_cf_logs.tf @@ -0,0 +1,171 @@ +module "s3bucket_cf_logs" { + source = "git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/s3bucket?ref=v1.0.9" + providers = { + aws = aws.us-east-1 + } + + name = "cf-logs" + + aws_account_id = var.aws_account_id + region = "us-east-1" + project = var.project + environment = var.environment + component = var.component + + acl = "private" + force_destroy = false + versioning = true + + object_ownership = "ObjectWriter" + + lifecycle_rules = [ + { + prefix = "" + enabled = true + + transition = [ + { + days = "90" + storage_class = "STANDARD_IA" + }, + { + days = "180" + storage_class = "GLACIER" + } + ] + + expiration = { + days = "365" + } + + + noncurrent_version_transition = [ + { + noncurrent_days = "30" + storage_class = "STANDARD_IA" + }, + { + noncurrent_days = "180" + storage_class = "GLACIER" + } + + ] + + noncurrent_version_expiration = { + noncurrent_days = "365" + } + + abort_incomplete_multipart_upload = { + days = "1" + } + } + ] + + policy_documents = [ + data.aws_iam_policy_document.s3bucket_cf_logs.json + ] + + public_access = { + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + } + + default_tags = { + Name = "Lambda function artefact bucket" + } +} + +data "aws_iam_policy_document" "s3bucket_cf_logs" { + statement { + sid = "DontAllowNonSecureConnection" + effect = "Deny" + + actions = [ + "s3:*", + ] + + resources = [ + module.s3bucket_cf_logs.arn, + "${module.s3bucket_cf_logs.arn}/*", + ] + + principals { + type = "AWS" + + identifiers = [ + "*", + ] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + + values = [ + "false", + ] + } + } + + statement { + effect = "Allow" + actions = ["s3:PutObject"] + resources = [ + "${module.s3bucket_cf_logs.arn}/*", + ] + + principals { + type = "Service" + identifiers = ["logging.s3.amazonaws.com"] + } + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = [ + var.aws_account_id + ] + } + } + + statement { + sid = "AllowManagedAccountsToList" + effect = "Allow" + + actions = [ + "s3:ListBucket", + ] + + resources = [ + module.s3bucket_cf_logs.arn, + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } + + statement { + sid = "AllowManagedAccountsToGet" + effect = "Allow" + + actions = [ + "s3:GetObject", + ] + + resources = [ + "${module.s3bucket_cf_logs.arn}/*", + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } +} diff --git a/infrastructure/terraform/modules/public-signing-keys/provider_aws.tf b/infrastructure/terraform/modules/public-signing-keys/provider_aws.tf new file mode 100644 index 00000000..5403bf35 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/provider_aws.tf @@ -0,0 +1,24 @@ +provider "aws" { + region = var.region + + allowed_account_ids = [ + var.aws_account_id, + ] + + default_tags { + tags = var.default_tags + } +} + +provider "aws" { + alias = "us-east-1" + region = "us-east-1" + + default_tags { + tags = var.default_tags + } + + allowed_account_ids = [ + var.aws_account_id, + ] +} diff --git a/infrastructure/terraform/modules/public-signing-keys/s3_bucket_policy_public_signing_keys.tf b/infrastructure/terraform/modules/public-signing-keys/s3_bucket_policy_public_signing_keys.tf new file mode 100644 index 00000000..5b98fc3f --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/s3_bucket_policy_public_signing_keys.tf @@ -0,0 +1,53 @@ +resource "aws_s3_bucket_policy" "public_signing_keys" { + bucket = module.s3bucket_public_keys.id + policy = data.aws_iam_policy_document.bucket_policy_public_signing_keys.json +} + +data "aws_iam_policy_document" "bucket_policy_public_signing_keys" { + statement { + actions = ["s3:GetObject"] + resources = [ + "${module.s3bucket_public_keys.arn}/*" + ] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.aws_account_id}:root", aws_cloudfront_origin_access_identity.signing_keys.iam_arn] + } + } + + statement { + actions = ["s3:ListBucket"] + resources = [ + module.s3bucket_public_keys.arn + ] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.aws_account_id}:root", aws_cloudfront_origin_access_identity.signing_keys.iam_arn] + } + } + + statement { + effect = "Deny" + actions = ["s3:*"] + resources = [ + module.s3bucket_public_keys.arn, + "${module.s3bucket_public_keys.arn}/*", + ] + + principals { + type = "AWS" + identifiers = ["*"] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = [ + false + ] + } + } +} + diff --git a/infrastructure/terraform/modules/public-signing-keys/s3_bucket_public_signing_keys.tf b/infrastructure/terraform/modules/public-signing-keys/s3_bucket_public_signing_keys.tf new file mode 100644 index 00000000..d778daa9 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/s3_bucket_public_signing_keys.tf @@ -0,0 +1,141 @@ +module "s3bucket_public_keys" { + source = "git::https://github.com/NHSDigital/nhs-notify-shared-modules.git//infrastructure/modules/s3bucket?ref=v1.0.9" + + name = "public-keys" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = var.component + + acl = "public-read" + force_destroy = false + versioning = true + + bucket_logging_target = { + bucket = var.acct.s3_buckets["access_logs"]["id"] + } + + lifecycle_rules = [ + { + enabled = true + + noncurrent_version_transition = [ + { + noncurrent_days = "30" + storage_class = "STANDARD_IA" + } + ] + + noncurrent_version_expiration = { + noncurrent_days = "90" + } + + abort_incomplete_multipart_upload = { + days = "1" + } + } + ] + + policy_documents = [ + data.aws_iam_policy_document.s3bucket_public_keys.json + ] + + public_access = { + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true + } + + + default_tags = { + Name = "Public signing keys for federated auth suppliers" + } +} + +data "aws_iam_policy_document" "s3bucket_public_keys" { + statement { + sid = "DontAllowNonSecureConnection" + effect = "Deny" + + actions = [ + "s3:*", + ] + + resources = [ + module.s3bucket_public_keys.arn, + "${module.s3bucket_public_keys.arn}/*", + ] + + principals { + type = "AWS" + + identifiers = [ + "*", + ] + } + + condition { + test = "Bool" + variable = "aws:SecureTransport" + + values = [ + "false", + ] + } + } + + statement { + sid = "AllowManagedAccountsToList" + effect = "Allow" + + actions = [ + "s3:ListBucket", + ] + + resources = [ + module.s3bucket_public_keys.arn, + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } + + statement { + sid = "AllowManagedAccountsToGet" + effect = "Allow" + + actions = [ + "s3:GetObject", + ] + + resources = [ + "${module.s3bucket_public_keys.arn}/*", + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } +} + +resource "aws_s3_bucket_cors_configuration" "public_public_keys" { + bucket = module.s3bucket_public_keys.bucket + + cors_rule { + allowed_headers = ["Authorization"] + allowed_methods = ["GET"] + allowed_origins = ["*"] + expose_headers = ["ETag"] + max_age_seconds = 300 + } +} diff --git a/infrastructure/terraform/modules/public-signing-keys/variables.tf b/infrastructure/terraform/modules/public-signing-keys/variables.tf new file mode 100644 index 00000000..00da2ef5 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/variables.tf @@ -0,0 +1,71 @@ +## +# Basic Required Variables for tfscaffold Components +## + +variable "project" { + type = string + description = "The name of the tfscaffold project" +} + +variable "environment" { + type = string + description = "The name of the tfscaffold environment" +} + +variable "aws_account_id" { + type = string + description = "The AWS Account ID (numeric)" +} + +variable "region" { + type = string + description = "The AWS Region" +} + +## +# tfscaffold variables specific to this component +## + +# This is the only primary variable to have its value defined as +# a default within its declaration in this file, because the variables +# purpose is as an identifier unique to this component, rather +# then to the environment from where all other variables come. +variable "component" { + type = string + description = "The variable encapsulating the name of this component" + default = "app" +} + +variable "default_tags" { + type = map(string) + description = "A map of default tags to apply to all taggable resources within the component" + default = {} +} + +variable "csi" { + type = string + description = "CSI from the parent component" +} + +variable "enable_github_actions_ip_access" { + type = bool + description = "Should the Github actions runner IP addresses be permitted access to this distribution. This should not be enabled in production environments" + default = false +} + +variable "waf_rate_limit_cdn" { + type = number + description = "The rate limit is the maximum number of CDN requests from a single IP address that are allowed in a five-minute period" + default = 20000 +} + +variable "acct" { + type = object({ + s3_buckets = object({ + access_logs = optional(object({ + id = optional(string) + })) + }) + }) + description = "Simplified account level settings" +} diff --git a/infrastructure/terraform/modules/public-signing-keys/wafv2_ip_set.tf b/infrastructure/terraform/modules/public-signing-keys/wafv2_ip_set.tf new file mode 100644 index 00000000..2f1755a8 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/wafv2_ip_set.tf @@ -0,0 +1,23 @@ +resource "aws_wafv2_ip_set" "github_actions_ipv4" { + count = var.enable_github_actions_ip_access ? 1 : 0 + + provider = aws.us-east-1 + + name = "${local.csi}-github-actions-ipv4" + description = "Public references for github actions runner IP addresses" + scope = "CLOUDFRONT" + ip_address_version = "IPV4" + addresses = data.github_ip_ranges.main.actions_ipv4 +} + +resource "aws_wafv2_ip_set" "github_actions_ipv6" { + count = var.enable_github_actions_ip_access ? 1 : 0 + + provider = aws.us-east-1 + + name = "${local.csi}-github-actions-ipv6" + description = "Public references for github actions runner IP addresses" + scope = "CLOUDFRONT" + ip_address_version = "IPV6" + addresses = data.github_ip_ranges.main.actions_ipv6 +} diff --git a/infrastructure/terraform/modules/public-signing-keys/wafv2_web_acl.tf b/infrastructure/terraform/modules/public-signing-keys/wafv2_web_acl.tf new file mode 100644 index 00000000..ec6547e9 --- /dev/null +++ b/infrastructure/terraform/modules/public-signing-keys/wafv2_web_acl.tf @@ -0,0 +1,179 @@ +resource "aws_wafv2_web_acl" "public_signing_keys" { + provider = aws.us-east-1 + + name = local.csi + description = "${var.environment} WAF" + scope = "CLOUDFRONT" + + default_action { + allow {} + } + + dynamic "rule" { + for_each = var.enable_github_actions_ip_access ? [1] : [] + + content { + name = "GithubActionsIPRestriction" + priority = 10 + + action { + allow {} + } + + statement { + or_statement { + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.github_actions_ipv4[0].arn + } + } + + statement { + ip_set_reference_statement { + arn = aws_wafv2_ip_set.github_actions_ipv6[0].arn + } + } + } + } + + visibility_config { + metric_name = "${local.csi}_gha_ip_restrictions_metric" + cloudwatch_metrics_enabled = true + sampled_requests_enabled = true + } + } + } + + rule { + name = "GeoLocationTrafficWhitelist" + priority = 20 + + action { + block {} + } + + statement { + not_statement { + statement { + geo_match_statement { + country_codes = ["GB"] + } + } + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + sampled_requests_enabled = true + metric_name = "${local.csi}_geo_location_whitelist" + } + } + + rule { + name = "AWSManagedRulesCommonRuleSet" + priority = 30 + override_action { + none {} + } + statement { + managed_rule_group_statement { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + + rule_action_override { + name = "GenericRFI_QUERYARGUMENTS" + action_to_use { + count {} + } + } + } + } + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf_aws_managed_common" + sampled_requests_enabled = true + } + } + + rule { + name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 40 + override_action { + none {} + } + statement { + managed_rule_group_statement { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + } + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf_aws_managed_input" + sampled_requests_enabled = true + } + } + + rule { + name = "AWSManagedRulesSQLiRuleSet" + priority = 50 + override_action { + none {} + } + statement { + managed_rule_group_statement { + name = "AWSManagedRulesSQLiRuleSet" + vendor_name = "AWS" + } + } + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf_aws_managed_sql" + sampled_requests_enabled = true + } + } + + rule { + name = "AWSManagedRulesAmazonIpReputationList" + priority = 60 + override_action { + none {} + } + statement { + managed_rule_group_statement { + name = "AWSManagedRulesAmazonIpReputationList" + vendor_name = "AWS" + } + } + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf_aws_managed_reputation" + sampled_requests_enabled = true + } + } + + rule { + name = "RateLimit" + priority = 100 + action { + block {} + } + statement { + rate_based_statement { + limit = var.waf_rate_limit_cdn + aggregate_key_type = "IP" + } + } + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf_rate_limit" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "${local.csi}_waf" + sampled_requests_enabled = true + } +}