From 27b9404ee795b4599b8b87d66624698518480021 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Wed, 20 Aug 2025 08:51:17 +0100 Subject: [PATCH 1/4] [NRL-1158] Allow AWS Backup to write to backup-reports bucket --- .../dev/aws-backup.tf | 17 ++++++++++++++++- .../account-wide-infrastructure/dev/data.tf | 2 ++ .../account-wide-infrastructure/dev/locals.tf | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/terraform/account-wide-infrastructure/dev/aws-backup.tf b/terraform/account-wide-infrastructure/dev/aws-backup.tf index 91d4813e6..f4aec0652 100644 --- a/terraform/account-wide-infrastructure/dev/aws-backup.tf +++ b/terraform/account-wide-infrastructure/dev/aws-backup.tf @@ -1,5 +1,4 @@ -# First, we create an S3 bucket for compliance reports. resource "aws_s3_bucket" "backup_reports" { bucket_prefix = "${local.prefix}-backup-reports" } @@ -45,6 +44,22 @@ resource "aws_s3_bucket_policy" "backup_reports_bucket_policy" { } } }, + { + Sid = "AllowBackupReportsWrite" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${local.account_id}:role/aws-service-role/reports.backup.amazonaws.com/AWSServiceRoleForBackupReports" + } + Action = "s3:PutObject" + Resource = [ + "${aws_s3_bucket.backup_reports.arn}/*", + ] + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + } ] }) } diff --git a/terraform/account-wide-infrastructure/dev/data.tf b/terraform/account-wide-infrastructure/dev/data.tf index bbf34c6c2..02e297f54 100644 --- a/terraform/account-wide-infrastructure/dev/data.tf +++ b/terraform/account-wide-infrastructure/dev/data.tf @@ -1,5 +1,7 @@ data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + data "aws_secretsmanager_secret_version" "identities_account_id" { secret_id = aws_secretsmanager_secret.identities_account_id.name } diff --git a/terraform/account-wide-infrastructure/dev/locals.tf b/terraform/account-wide-infrastructure/dev/locals.tf index 06c8c4221..43a8e7bb5 100644 --- a/terraform/account-wide-infrastructure/dev/locals.tf +++ b/terraform/account-wide-infrastructure/dev/locals.tf @@ -3,6 +3,7 @@ locals { project = "nhsd-nrlf" environment = terraform.workspace prefix = "${local.project}--${local.environment}" + account_id = data.aws_caller_identity.current.account_id notification_emails = tolist(jsondecode(data.aws_secretsmanager_secret_version.emails.secret_string)) } From 15e1880f009ff0d8ec74586bc04202492d8f7d6a Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Wed, 20 Aug 2025 21:18:13 +0100 Subject: [PATCH 2/4] [NRL-1158] Switch sns config for backups to match AWS examples --- .../modules/backup-source/sns.tf | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/terraform/account-wide-infrastructure/modules/backup-source/sns.tf b/terraform/account-wide-infrastructure/modules/backup-source/sns.tf index c0bb6827c..7f0d571b5 100644 --- a/terraform/account-wide-infrastructure/modules/backup-source/sns.tf +++ b/terraform/account-wide-infrastructure/modules/backup-source/sns.tf @@ -1,7 +1,6 @@ resource "aws_sns_topic" "backup" { name = "${local.resource_name_prefix}-notifications" kms_master_key_id = var.bootstrap_kms_key_arn - policy = data.aws_iam_policy_document.allow_backup_to_sns.json } data "aws_iam_policy_document" "allow_backup_to_sns" { @@ -19,12 +18,25 @@ data "aws_iam_policy_document" "allow_backup_to_sns" { identifiers = ["backup.amazonaws.com"] } - resources = ["*"] + resources = [ + aws_sns_topic.backup.arn + ] sid = "allow_backup" + + condition { + test = "StringEquals" + variable = "aws:SourceAccount" + values = ["${data.aws_caller_identity.current.account_id}"] + } } } +resource "aws_sns_topic_policy" "backup_sns_policy" { + arn = aws_sns_topic.backup.arn + policy = data.aws_iam_policy_document.allow_backup_to_sns.json +} + resource "aws_sns_topic_subscription" "aws_backup_notifications_email_target" { count = length(var.notification_target_email_addresses) topic_arn = aws_sns_topic.backup.arn From 9e6701c7413244aed32ea2ea7667c4337c4ae068 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Wed, 20 Aug 2025 21:45:12 +0100 Subject: [PATCH 3/4] [NRL-1158] Add test and prod aws-backup infra --- .../prod/aws-backup.tf | 187 ++++++++++++++++++ .../account-wide-infrastructure/prod/data.tf | 6 + .../prod/locals.tf | 1 + .../prod/secrets.tf | 5 + .../test/aws-backup.tf | 187 ++++++++++++++++++ .../account-wide-infrastructure/test/data.tf | 7 +- .../test/locals.tf | 1 + .../test/secrets.tf | 5 +- 8 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 terraform/account-wide-infrastructure/prod/aws-backup.tf create mode 100644 terraform/account-wide-infrastructure/test/aws-backup.tf diff --git a/terraform/account-wide-infrastructure/prod/aws-backup.tf b/terraform/account-wide-infrastructure/prod/aws-backup.tf new file mode 100644 index 000000000..f4aec0652 --- /dev/null +++ b/terraform/account-wide-infrastructure/prod/aws-backup.tf @@ -0,0 +1,187 @@ + +resource "aws_s3_bucket" "backup_reports" { + bucket_prefix = "${local.prefix}-backup-reports" +} + +resource "aws_s3_bucket_public_access_block" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.bucket + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_policy" "backup_reports_bucket_policy" { + bucket = aws_s3_bucket.backup_reports.id + + policy = jsonencode({ + Version = "2012-10-17" + Id = "backup_reports_bucket_policy" + Statement = [ + { + Sid = "HTTPSOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.backup_reports.arn, + "${aws_s3_bucket.backup_reports.arn}/*", + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "AllowBackupReportsWrite" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${local.account_id}:role/aws-service-role/reports.backup.amazonaws.com/AWSServiceRoleForBackupReports" + } + Action = "s3:PutObject" + Resource = [ + "${aws_s3_bucket.backup_reports.arn}/*", + ] + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + } + ] + }) +} + + +resource "aws_s3_bucket_ownership_controls" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.id + rule { + object_ownership = "BucketOwnerPreferred" + } +} + +resource "aws_s3_bucket_acl" "backup_reports" { + depends_on = [aws_s3_bucket_ownership_controls.backup_reports] + + bucket = aws_s3_bucket.backup_reports.id + acl = "private" +} + +resource "aws_kms_key" "backup_notifications" { + description = "KMS key for AWS Backup notifications" + deletion_window_in_days = 7 + enable_key_rotation = true + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Sid = "Enable IAM User Permissions" + Principal = { + AWS = "arn:aws:iam::${var.assume_account}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Effect = "Allow" + Principal = { + Service = "sns.amazonaws.com" + } + Action = ["kms:GenerateDataKey*", "kms:Decrypt"] + Resource = "*" + }, + ] + }) +} + +module "source" { + source = "../modules/backup-source" + + backup_copy_vault_account_id = jsondecode(data.aws_secretsmanager_secret_version.backup_destination_parameters.secret_string)["account-id"] + backup_copy_vault_arn = jsondecode(data.aws_secretsmanager_secret_version.backup_destination_parameters.secret_string)["vault-arn"] + environment_name = local.environment + bootstrap_kms_key_arn = aws_kms_key.backup_notifications.arn + project_name = "${local.prefix}-" + reports_bucket = aws_s3_bucket.backup_reports.bucket + terraform_role_arn = "arn:aws:iam::${var.assume_account}:role/${var.assume_role}" + + notification_target_email_addresses = local.notification_emails + + backup_plan_config = { + "compliance_resource_types" : [ + "S3" + ], + "enable" : true, + "rules" : [ + { + "copy_action" : [{ + "delete_after" : 4, + }], + "lifecycle" : { + "delete_after" : 2 + }, + "name" : "daily_kept_for_2_days", + "schedule" : "cron(0 0 * * ? *)" + } + ], + "selection_tag" : "NHSE-Enable-S3-Backup" + } + + backup_plan_config_dynamodb = { + "compliance_resource_types" : [ + "DynamoDB" + ], + "enable" : true, + "rules" : [ + { + "name" : "daily", + "schedule" : "cron(0 0 * * ? *)", + "copy_action" : [{ + "delete_after" : 4, + }], + + "lifecycle" : { + "delete_after" : 2 + } + }, + { + "name" : "monthly" + "schedule" : "cron(30 0 ? * 4#1)" # first Thursday each month from 00:30 + "copy_action" : [{ + "cold_storage_after" : 3, + "delete_after" : 100 # ensures there will always be min 3 + }], + "lifecycle" : { + "delete_after" : 2 + } + + }, + { + "name" : "weekly" # overlaps with monthly + "schedule" : "cron(30 0 ? * 4)" # every Thursday from 00:30 to precede releases + "copy_action" : [{ + "cold_storage_after" : 14 # ensures 2 warm including one from previous release + "delete_after" : 105 + }], + "lifecycle" : { + "delete_after" : 2 + } + + } + ], + "selection_tag" : "NHSE-Enable-DDB-Backup" + } +} diff --git a/terraform/account-wide-infrastructure/prod/data.tf b/terraform/account-wide-infrastructure/prod/data.tf index 1d974a10e..c21a0cb4d 100644 --- a/terraform/account-wide-infrastructure/prod/data.tf +++ b/terraform/account-wide-infrastructure/prod/data.tf @@ -1,5 +1,7 @@ data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + data "aws_secretsmanager_secret_version" "identities_account_id" { secret_id = aws_secretsmanager_secret.identities_account_id.name } @@ -11,3 +13,7 @@ data "aws_secretsmanager_secret" "emails" { data "aws_secretsmanager_secret_version" "emails" { secret_id = data.aws_secretsmanager_secret.emails.id } + +data "aws_secretsmanager_secret_version" "backup_destination_parameters" { + secret_id = aws_secretsmanager_secret.backup_destination_parameters.name +} diff --git a/terraform/account-wide-infrastructure/prod/locals.tf b/terraform/account-wide-infrastructure/prod/locals.tf index 06c8c4221..43a8e7bb5 100644 --- a/terraform/account-wide-infrastructure/prod/locals.tf +++ b/terraform/account-wide-infrastructure/prod/locals.tf @@ -3,6 +3,7 @@ locals { project = "nhsd-nrlf" environment = terraform.workspace prefix = "${local.project}--${local.environment}" + account_id = data.aws_caller_identity.current.account_id notification_emails = tolist(jsondecode(data.aws_secretsmanager_secret_version.emails.secret_string)) } diff --git a/terraform/account-wide-infrastructure/prod/secrets.tf b/terraform/account-wide-infrastructure/prod/secrets.tf index f269927eb..1547a9c36 100644 --- a/terraform/account-wide-infrastructure/prod/secrets.tf +++ b/terraform/account-wide-infrastructure/prod/secrets.tf @@ -30,3 +30,8 @@ resource "aws_secretsmanager_secret" "powerbi_gw_recovery_key" { name = "${local.project}--prod-powerbi-gw-recovery-key" description = "Recovery key for the PowerBI Gateway EC2 instance" } + +resource "aws_secretsmanager_secret" "backup_destination_parameters" { + name = "${local.prefix}--backup-destination-parameters" + description = "Parameters used to configure the backup destination" +} diff --git a/terraform/account-wide-infrastructure/test/aws-backup.tf b/terraform/account-wide-infrastructure/test/aws-backup.tf new file mode 100644 index 000000000..f4aec0652 --- /dev/null +++ b/terraform/account-wide-infrastructure/test/aws-backup.tf @@ -0,0 +1,187 @@ + +resource "aws_s3_bucket" "backup_reports" { + bucket_prefix = "${local.prefix}-backup-reports" +} + +resource "aws_s3_bucket_public_access_block" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.bucket + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_policy" "backup_reports_bucket_policy" { + bucket = aws_s3_bucket.backup_reports.id + + policy = jsonencode({ + Version = "2012-10-17" + Id = "backup_reports_bucket_policy" + Statement = [ + { + Sid = "HTTPSOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.backup_reports.arn, + "${aws_s3_bucket.backup_reports.arn}/*", + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + { + Sid = "AllowBackupReportsWrite" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${local.account_id}:role/aws-service-role/reports.backup.amazonaws.com/AWSServiceRoleForBackupReports" + } + Action = "s3:PutObject" + Resource = [ + "${aws_s3_bucket.backup_reports.arn}/*", + ] + Condition = { + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + } + ] + }) +} + + +resource "aws_s3_bucket_ownership_controls" "backup_reports" { + bucket = aws_s3_bucket.backup_reports.id + rule { + object_ownership = "BucketOwnerPreferred" + } +} + +resource "aws_s3_bucket_acl" "backup_reports" { + depends_on = [aws_s3_bucket_ownership_controls.backup_reports] + + bucket = aws_s3_bucket.backup_reports.id + acl = "private" +} + +resource "aws_kms_key" "backup_notifications" { + description = "KMS key for AWS Backup notifications" + deletion_window_in_days = 7 + enable_key_rotation = true + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Sid = "Enable IAM User Permissions" + Principal = { + AWS = "arn:aws:iam::${var.assume_account}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Effect = "Allow" + Principal = { + Service = "sns.amazonaws.com" + } + Action = ["kms:GenerateDataKey*", "kms:Decrypt"] + Resource = "*" + }, + ] + }) +} + +module "source" { + source = "../modules/backup-source" + + backup_copy_vault_account_id = jsondecode(data.aws_secretsmanager_secret_version.backup_destination_parameters.secret_string)["account-id"] + backup_copy_vault_arn = jsondecode(data.aws_secretsmanager_secret_version.backup_destination_parameters.secret_string)["vault-arn"] + environment_name = local.environment + bootstrap_kms_key_arn = aws_kms_key.backup_notifications.arn + project_name = "${local.prefix}-" + reports_bucket = aws_s3_bucket.backup_reports.bucket + terraform_role_arn = "arn:aws:iam::${var.assume_account}:role/${var.assume_role}" + + notification_target_email_addresses = local.notification_emails + + backup_plan_config = { + "compliance_resource_types" : [ + "S3" + ], + "enable" : true, + "rules" : [ + { + "copy_action" : [{ + "delete_after" : 4, + }], + "lifecycle" : { + "delete_after" : 2 + }, + "name" : "daily_kept_for_2_days", + "schedule" : "cron(0 0 * * ? *)" + } + ], + "selection_tag" : "NHSE-Enable-S3-Backup" + } + + backup_plan_config_dynamodb = { + "compliance_resource_types" : [ + "DynamoDB" + ], + "enable" : true, + "rules" : [ + { + "name" : "daily", + "schedule" : "cron(0 0 * * ? *)", + "copy_action" : [{ + "delete_after" : 4, + }], + + "lifecycle" : { + "delete_after" : 2 + } + }, + { + "name" : "monthly" + "schedule" : "cron(30 0 ? * 4#1)" # first Thursday each month from 00:30 + "copy_action" : [{ + "cold_storage_after" : 3, + "delete_after" : 100 # ensures there will always be min 3 + }], + "lifecycle" : { + "delete_after" : 2 + } + + }, + { + "name" : "weekly" # overlaps with monthly + "schedule" : "cron(30 0 ? * 4)" # every Thursday from 00:30 to precede releases + "copy_action" : [{ + "cold_storage_after" : 14 # ensures 2 warm including one from previous release + "delete_after" : 105 + }], + "lifecycle" : { + "delete_after" : 2 + } + + } + ], + "selection_tag" : "NHSE-Enable-DDB-Backup" + } +} diff --git a/terraform/account-wide-infrastructure/test/data.tf b/terraform/account-wide-infrastructure/test/data.tf index a8146ceee..c21a0cb4d 100644 --- a/terraform/account-wide-infrastructure/test/data.tf +++ b/terraform/account-wide-infrastructure/test/data.tf @@ -1,10 +1,11 @@ data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + data "aws_secretsmanager_secret_version" "identities_account_id" { secret_id = aws_secretsmanager_secret.identities_account_id.name } - data "aws_secretsmanager_secret" "emails" { name = "${local.prefix}-emails" } @@ -12,3 +13,7 @@ data "aws_secretsmanager_secret" "emails" { data "aws_secretsmanager_secret_version" "emails" { secret_id = data.aws_secretsmanager_secret.emails.id } + +data "aws_secretsmanager_secret_version" "backup_destination_parameters" { + secret_id = aws_secretsmanager_secret.backup_destination_parameters.name +} diff --git a/terraform/account-wide-infrastructure/test/locals.tf b/terraform/account-wide-infrastructure/test/locals.tf index 06c8c4221..43a8e7bb5 100644 --- a/terraform/account-wide-infrastructure/test/locals.tf +++ b/terraform/account-wide-infrastructure/test/locals.tf @@ -3,6 +3,7 @@ locals { project = "nhsd-nrlf" environment = terraform.workspace prefix = "${local.project}--${local.environment}" + account_id = data.aws_caller_identity.current.account_id notification_emails = tolist(jsondecode(data.aws_secretsmanager_secret_version.emails.secret_string)) } diff --git a/terraform/account-wide-infrastructure/test/secrets.tf b/terraform/account-wide-infrastructure/test/secrets.tf index d431c29c7..903628d84 100644 --- a/terraform/account-wide-infrastructure/test/secrets.tf +++ b/terraform/account-wide-infrastructure/test/secrets.tf @@ -2,7 +2,6 @@ resource "aws_secretsmanager_secret" "identities_account_id" { name = "${local.prefix}--nhs-identities-account-id" } -// TODO-NOW - Get apigee app config for test/qa smoke tests resource "aws_secretsmanager_secret" "qa_smoke_test_apigee_app" { name = "${local.prefix}--qa--apigee-app--smoke-test" description = "APIGEE App used to run Smoke Tests against the QA environment" @@ -18,6 +17,10 @@ resource "aws_secretsmanager_secret" "ref_smoke_test_apigee_app" { description = "APIGEE App used to run Smoke Tests against the REF environment" } +resource "aws_secretsmanager_secret" "backup_destination_parameters" { + name = "${local.prefix}--backup-destination-parameters" + description = "Parameters used to configure the backup destination" +} # # Smoke test parameters secrets From d086f8864ff23003c014f55feeaca71766704c71 Mon Sep 17 00:00:00 2001 From: Matt Dean Date: Thu, 21 Aug 2025 08:52:40 +0100 Subject: [PATCH 4/4] [NRL-1158] Allow for unset copy_action in backup config --- .../modules/backup-source/backup_plan.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf index b352430b7..2b9951f30 100644 --- a/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf @@ -17,7 +17,7 @@ resource "aws_backup_plan" "default" { cold_storage_after = rule.value.lifecycle.cold_storage_after } dynamic "copy_action" { - for_each = rule.value.copy_action + for_each = rule.value.copy_action != null ? rule.value.copy_action : [] content { lifecycle { delete_after = copy_action.value.delete_after @@ -48,7 +48,7 @@ resource "aws_backup_plan" "dynamodb" { cold_storage_after = rule.value.lifecycle.cold_storage_after } dynamic "copy_action" { - for_each = rule.value.copy_action + for_each = rule.value.copy_action != null ? rule.value.copy_action : [] content { lifecycle { delete_after = copy_action.value.delete_after