diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 89eb4baa3..b3e9069b2 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,4 +1,6 @@ #!/bin/bash +# Setup mgmt and non-mgmt AWS accounts for NRLF +set -o errexit -o nounset -o pipefail AWS_REGION_NAME="eu-west-2" PROFILE_PREFIX="nhsd-nrlf" @@ -32,18 +34,12 @@ function _check_mgmt() { } function _check_non_mgmt() { - if [[ "$(aws iam list-account-aliases --query 'AccountAliases[0]' --output text)" != 'nhsd-ddc-spine-nrlf-mgmt' ]]; then + if [[ "$(aws iam list-account-aliases --query 'AccountAliases[0]' --output text)" == 'nhsd-ddc-spine-nrlf-mgmt' ]]; then echo "Please log in as a non-mgmt account" >&2 return 1 fi } -function _get_mgmt_account(){ - if ! _check_mgmt; then return 1; fi - return $(aws sts get-caller-identity --query Account --output text) -} - - function _bootstrap() { local command=$1 local admin_policy_arn="arn:aws:iam::aws:policy/AdministratorAccess" @@ -55,7 +51,7 @@ function _bootstrap() { "create-mgmt") _check_mgmt || return 1 - cd $root/terraform/bootstrap/mgmt + cd terraform/bootstrap/mgmt aws s3api create-bucket --bucket "${truststore_bucket_name}" --region us-east-1 --create-bucket-configuration LocationConstraint="${AWS_REGION_NAME}" aws s3api create-bucket --bucket "${state_bucket_name}" --region us-east-1 --create-bucket-configuration LocationConstraint="${AWS_REGION_NAME}" aws s3api put-public-access-block --bucket "${state_bucket_name}" --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" @@ -69,7 +65,7 @@ function _bootstrap() { "delete-mgmt") _check_mgmt || return 1 - cd $root/terraform/bootstrap/mgmt + cd terraform/bootstrap/mgmt aws dynamodb delete-table --table-name "${state_lock_table_name}" || return 1 local versioned_objects versioned_objects=$(aws s3api list-object-versions \ @@ -90,10 +86,20 @@ function _bootstrap() { "create-non-mgmt") _check_non_mgmt || return 1 - cd $root/terraform/bootstrap/non-mgmt + cd terraform/bootstrap/non-mgmt local tf_assume_role_policy local mgmt_account_id - mgmt_account_id=$(_get_mgmt_account) + + set +e + mgmt_account_id=$(aws secretsmanager get-secret-value --secret-id "${MGMT_ACCOUNT_ID_LOCATION}" --query SecretString --output text) + + if [ "${mgmt_account_id}" == "" ]; then + aws secretsmanager create-secret --name "${MGMT_ACCOUNT_ID_LOCATION}" + echo "Please set ${MGMT_ACCOUNT_ID_LOCATION} in the Secrets Manager and rerun the script" + exit 1 + fi + set -e + tf_assume_role_policy=$(awk "{sub(/REPLACEME/,\"${mgmt_account_id}\")}1" terraform-trust-policy.json) aws iam create-role --role-name "${TERRAFORM_ROLE_NAME}" --assume-role-policy-document "${tf_assume_role_policy}" || return 1 aws iam attach-role-policy --policy-arn "${admin_policy_arn}" --role-name "${TERRAFORM_ROLE_NAME}" || return 1 diff --git a/terraform/account-wide-infrastructure/dev/aws-backup.tf b/terraform/account-wide-infrastructure/dev/aws-backup.tf new file mode 100644 index 000000000..fc41d32a8 --- /dev/null +++ b/terraform/account-wide-infrastructure/dev/aws-backup.tf @@ -0,0 +1,158 @@ + +# First, we create an S3 bucket for compliance reports. +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" + } + } + }, + ] + }) +} + + +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" +} + +# We need a key for the SNS topic that will be used for notifications from AWS Backup. This key +# will be used to encrypt the messages sent to the topic before they are sent to the subscribers, +# but isn't needed by the recipients of the messages. + +# First we need some contextual data +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +# Now we can define the key itself +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 = "*" + }, + ] + }) +} + +# Now we can deploy the source and destination modules, referencing the resources we've created above. + +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" : [ + { + "copy_action" : { + "delete_after" : 4 + }, + "lifecycle" : { + "delete_after" : 2 + }, + "name" : "daily_kept_for_2_days", + "schedule" : "cron(0 0 * * ? *)" + } + ], + "selection_tag" : "NHSE-Enable-DDB-Backup" + } +} diff --git a/terraform/account-wide-infrastructure/dev/cloudwatch.tf b/terraform/account-wide-infrastructure/dev/cloudwatch.tf index 8a54e5854..031a6e36d 100644 --- a/terraform/account-wide-infrastructure/dev/cloudwatch.tf +++ b/terraform/account-wide-infrastructure/dev/cloudwatch.tf @@ -2,6 +2,8 @@ module "lambda_errors_cloudwatch_metric_alarm_dev" { source = "../modules/lambda-errors-metric-alarm" name_prefix = "nhsd-nrlf--dev" + notification_emails = local.notification_emails + evaluation_periods = 1 period = 60 threshold = 1 diff --git a/terraform/account-wide-infrastructure/dev/data.tf b/terraform/account-wide-infrastructure/dev/data.tf index fe0eefc7c..bb435ed6b 100644 --- a/terraform/account-wide-infrastructure/dev/data.tf +++ b/terraform/account-wide-infrastructure/dev/data.tf @@ -1,3 +1,15 @@ data "aws_secretsmanager_secret_version" "identities_account_id" { secret_id = aws_secretsmanager_secret.identities_account_id.name } + +data "aws_secretsmanager_secret_version" "backup_destination_parameters" { + secret_id = aws_secretsmanager_secret.backup_destination_parameters.name +} + +data "aws_secretsmanager_secret" "emails" { + name = "${local.prefix}-emails" +} + +data "aws_secretsmanager_secret_version" "emails" { + secret_id = data.aws_secretsmanager_secret.emails.id +} diff --git a/terraform/account-wide-infrastructure/dev/dynamodb__pointers-table.tf b/terraform/account-wide-infrastructure/dev/dynamodb__pointers-table.tf index fccaa1b00..4a6403208 100644 --- a/terraform/account-wide-infrastructure/dev/dynamodb__pointers-table.tf +++ b/terraform/account-wide-infrastructure/dev/dynamodb__pointers-table.tf @@ -1,6 +1,7 @@ module "dev-pointers-table" { - source = "../modules/pointers-table" - name_prefix = "nhsd-nrlf--dev" + source = "../modules/pointers-table" + name_prefix = "nhsd-nrlf--dev" + enable_backups = true } module "dev-sandbox-pointers-table" { diff --git a/terraform/account-wide-infrastructure/dev/locals.tf b/terraform/account-wide-infrastructure/dev/locals.tf index 0929b0d38..9b06efdfe 100644 --- a/terraform/account-wide-infrastructure/dev/locals.tf +++ b/terraform/account-wide-infrastructure/dev/locals.tf @@ -3,4 +3,6 @@ locals { project = "nhsd-nrlf" environment = terraform.workspace prefix = "${local.project}--${local.environment}" + + notification_emails = nonsensitive(toset(tolist(jsondecode(data.aws_secretsmanager_secret_version.emails.secret_string)))) } diff --git a/terraform/account-wide-infrastructure/dev/main.tf b/terraform/account-wide-infrastructure/dev/main.tf index cfed956f2..6a15ca71b 100644 --- a/terraform/account-wide-infrastructure/dev/main.tf +++ b/terraform/account-wide-infrastructure/dev/main.tf @@ -10,7 +10,14 @@ provider "aws" { workspace = terraform.workspace } } +} + +provider "awscc" { + region = local.region + assume_role = { + role_arn = "arn:aws:iam::${var.assume_account}:role/${var.assume_role}" + } } terraform { diff --git a/terraform/account-wide-infrastructure/dev/s3.tf b/terraform/account-wide-infrastructure/dev/s3.tf index 472189d41..b90bf677f 100644 --- a/terraform/account-wide-infrastructure/dev/s3.tf +++ b/terraform/account-wide-infrastructure/dev/s3.tf @@ -1,6 +1,7 @@ module "dev-permissions-store-bucket" { - source = "../modules/permissions-store-bucket" - name_prefix = "nhsd-nrlf--dev" + source = "../modules/permissions-store-bucket" + name_prefix = "nhsd-nrlf--dev" + enable_backups = true } module "dev-sandbox-permissions-store-bucket" { @@ -12,6 +13,7 @@ module "dev-truststore-bucket" { source = "../modules/truststore-bucket" name_prefix = "nhsd-nrlf--dev" server_certificate_file = "../../../truststore/server/dev.pem" + enable_backups = true } module "dev-sandbox-truststore-bucket" { diff --git a/terraform/account-wide-infrastructure/dev/secrets.tf b/terraform/account-wide-infrastructure/dev/secrets.tf index 46c339fc9..bc9b0a3cc 100644 --- a/terraform/account-wide-infrastructure/dev/secrets.tf +++ b/terraform/account-wide-infrastructure/dev/secrets.tf @@ -2,6 +2,15 @@ resource "aws_secretsmanager_secret" "identities_account_id" { name = "${local.prefix}--nhs-identities-account-id" } +resource "aws_secretsmanager_secret" "backup_destination_parameters" { + name = "${local.prefix}--backup-destination-parameters" + description = "Parameters used to configure the backup destination" +} + +resource "aws_secretsmanager_secret" "notification_email_addresses" { + name = "${local.prefix}-dev-notification-email-addresses" +} + resource "aws_secretsmanager_secret" "dev_smoke_test_apigee_app" { name = "${local.prefix}--dev--apigee-app--smoke-test" description = "APIGEE App used to run Smoke Tests against the DEV environment" diff --git a/terraform/account-wide-infrastructure/modules/backup-source/README.md b/terraform/account-wide-infrastructure/modules/backup-source/README.md new file mode 100644 index 000000000..3f1b8bdb6 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/README.md @@ -0,0 +1,37 @@ +# AWS Backup Module + +The AWS Backup Module helps automates the setup of AWS Backup resources in a source account. It streamlines the process of creating, managing, and standardising backup configurations. + +## Inputs + +| Name | Description | Type | Default | Required | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | +| [backup_copy_vault_account_id](#input_backup_copy_vault_account_id) | The account id of the destination backup vault for allowing restores back into the source account. | `string` | `""` | no | +| [backup_copy_vault_arn](#input_backup_copy_vault_arn) | The ARN of the destination backup vault for cross-account backup copies. | `string` | `""` | no | +| [backup_plan_config](#input_backup_plan_config) | Configuration for backup plans |
object({
selection_tag = string
compliance_resource_types = list(string)
rules = list(object({
name = string
schedule = string
enable_continuous_backup = optional(bool)
lifecycle = object({
delete_after = optional(number)
cold_storage_after = optional(number)
})
copy_action = optional(object({
delete_after = optional(number)
}))
}))
}) | {
"compliance_resource_types": [
"S3"
],
"rules": [
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"delete_after": 35
},
"name": "daily_kept_5_weeks",
"schedule": "cron(0 0 * * ? *)"
},
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"delete_after": 90
},
"name": "weekly_kept_3_months",
"schedule": "cron(0 1 ? * SUN *)"
},
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"cold_storage_after": 30,
"delete_after": 2555
},
"name": "monthly_kept_7_years",
"schedule": "cron(0 2 1 * ? *)"
},
{
"copy_action": {
"delete_after": 365
},
"enable_continuous_backup": true,
"lifecycle": {
"delete_after": 35
},
"name": "point_in_time_recovery",
"schedule": "cron(0 5 * * ? *)"
}
],
"selection_tag": "BackupLocal"
} | no |
+| [backup_plan_config_dynamodb](#input_backup_plan_config_dynamodb) | Configuration for backup plans with dynamodb | object({
enable = bool
selection_tag = string
compliance_resource_types = list(string)
rules = optional(list(object({
name = string
schedule = string
enable_continuous_backup = optional(bool)
lifecycle = object({
delete_after = number
cold_storage_after = optional(number)
})
copy_action = optional(object({
delete_after = optional(number)
}))
})))
}) | {
"compliance_resource_types": [
"DynamoDB"
],
"enable": true,
"rules": [
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"delete_after": 35
},
"name": "dynamodb_daily_kept_5_weeks",
"schedule": "cron(0 0 * * ? *)"
},
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"delete_after": 90
},
"name": "dynamodb_weekly_kept_3_months",
"schedule": "cron(0 1 ? * SUN *)"
},
{
"copy_action": {
"delete_after": 365
},
"lifecycle": {
"cold_storage_after": 30,
"delete_after": 2555
},
"name": "dynamodb_monthly_kept_7_years",
"schedule": "cron(0 2 1 * ? *)"
}
],
"selection_tag": "BackupDynamoDB"
} | no |
+| [bootstrap_kms_key_arn](#input_bootstrap_kms_key_arn) | The ARN of the bootstrap KMS key used for encryption at rest of the SNS topic. | `string` | n/a | yes |
+| [environment_name](#input_environment_name) | The name of the environment where AWS Backup is configured. | `string` | n/a | yes |
+| [notifications_target_email_address](#input_notifications_target_email_address) | The email address to which backup notifications will be sent via SNS. | `string` | `""` | no |
+| [project_name](#input_project_name) | The name of the project this relates to. | `string` | n/a | yes |
+| [reports_bucket](#input_reports_bucket) | Bucket to drop backup reports into | `string` | n/a | yes |
+| [restore_testing_plan_algorithm](#input_restore_testing_plan_algorithm) | Algorithm of the Recovery Selection Point | `string` | `"LATEST_WITHIN_WINDOW"` | no |
+| [restore_testing_plan_recovery_point_types](#input_restore_testing_plan_recovery_point_types) | Recovery Point Types | `list(string)` | [| no | +| [restore_testing_plan_scheduled_expression](#input_restore_testing_plan_scheduled_expression) | Scheduled Expression of Recovery Selection Point | `string` | `"cron(0 1 ? * SUN *)"` | no | +| [restore_testing_plan_selection_window_days](#input_restore_testing_plan_selection_window_days) | Selection window days | `number` | `7` | no | +| [restore_testing_plan_start_window](#input_restore_testing_plan_start_window) | Start window from the scheduled time during which the test should start | `number` | `1` | no | +| [terraform_role_arn](#input_terraform_role_arn) | ARN of Terraform role used to deploy to account | `string` | n/a | yes | + +## Example + +```terraform +module "test_aws_backup" { + source = "./modules/aws-backup" + + environment_name = "environment_name" + bootstrap_kms_key_arn = kms_key[0].arn + project_name = "testproject" + reports_bucket = "compliance-reports" + terraform_role_arn = data.aws_iam_role.terraform_role.arn +} +``` diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_framework.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_framework.tf new file mode 100644 index 000000000..d10b43137 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_framework.tf @@ -0,0 +1,149 @@ +resource "aws_backup_framework" "main" { + # must be underscores instead of dashes + name = replace("${local.resource_name_prefix}-framework", "-", "_") + description = "${var.project_name} Backup Framework" + + # Evaluates if recovery points are encrypted. + control { + name = "BACKUP_RECOVERY_POINT_ENCRYPTED" + + scope { + tags = { + "environment_name" = var.environment_name + } + } + } + + # Evaluates if backup vaults do not allow manual deletion of recovery points with the exception of certain IAM roles. + control { + name = "BACKUP_RECOVERY_POINT_MANUAL_DELETION_DISABLED" + + scope { + tags = { + "environment_name" = var.environment_name + } + } + + input_parameter { + name = "principalArnList" + value = var.terraform_role_arn + } + } + + # Evaluates if recovery point retention period is at least 1 month. + control { + name = "BACKUP_RECOVERY_POINT_MINIMUM_RETENTION_CHECK" + + scope { + tags = { + "environment_name" = var.environment_name + } + } + + input_parameter { + name = "requiredRetentionDays" + value = "35" + } + } + + # Evaluates if backup plan creates backups at least every 1 day and retains them for at least 1 month before deleting them. + control { + name = "BACKUP_PLAN_MIN_FREQUENCY_AND_MIN_RETENTION_CHECK" + + scope { + tags = { + "environment_name" = var.environment_name + } + } + + input_parameter { + name = "requiredFrequencyUnit" + value = "days" + } + + input_parameter { + name = "requiredRetentionDays" + value = "35" + } + + input_parameter { + name = "requiredFrequencyValue" + value = "1" + } + } + + # Evaluates if resources are protected by a backup plan. + control { + name = "BACKUP_RESOURCES_PROTECTED_BY_BACKUP_PLAN" + + scope { + compliance_resource_types = var.backup_plan_config.compliance_resource_types + tags = { + (var.backup_plan_config.selection_tag) = "True" + } + } + } + + # Evaluates if resources have at least one recovery point created within the past 1 day. + control { + name = "BACKUP_LAST_RECOVERY_POINT_CREATED" + + input_parameter { + name = "recoveryPointAgeUnit" + value = "days" + } + + input_parameter { + name = "recoveryPointAgeValue" + value = "1" + } + + scope { + compliance_resource_types = var.backup_plan_config.compliance_resource_types + tags = { + (var.backup_plan_config.selection_tag) = "True" + } + } + } +} + +resource "aws_backup_framework" "dynamodb" { + count = var.backup_plan_config_dynamodb.enable ? 1 : 0 + # must be underscores instead of dashes + name = replace("${local.resource_name_prefix}-dynamodb-framework", "-", "_") + description = "${var.project_name} DynamoDB Backup Framework" + + # Evaluates if resources are protected by a backup plan. + control { + name = "BACKUP_RESOURCES_PROTECTED_BY_BACKUP_PLAN" + + scope { + compliance_resource_types = var.backup_plan_config_dynamodb.compliance_resource_types + tags = { + (var.backup_plan_config_dynamodb.selection_tag) = "True" + } + } + } + + # Evaluates if resources have at least one recovery point created within the past 1 day. + control { + name = "BACKUP_LAST_RECOVERY_POINT_CREATED" + + input_parameter { + name = "recoveryPointAgeUnit" + value = "days" + } + + input_parameter { + name = "recoveryPointAgeValue" + value = "1" + } + + scope { + compliance_resource_types = var.backup_plan_config_dynamodb.compliance_resource_types + tags = { + (var.backup_plan_config_dynamodb.selection_tag) = "True" + } + } + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_notification.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_notification.tf new file mode 100644 index 000000000..554f2ad49 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_notification.tf @@ -0,0 +1,11 @@ +resource "aws_backup_vault_notifications" "backup_notification" { + backup_vault_name = aws_backup_vault.main.name + sns_topic_arn = aws_sns_topic.backup.arn + backup_vault_events = [ + "BACKUP_JOB_COMPLETED", + "RESTORE_JOB_COMPLETED", + "S3_BACKUP_OBJECT_FAILED", + "S3_RESTORE_OBJECT_FAILED", + "COPY_JOB_FAILED" + ] +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf new file mode 100644 index 000000000..0e6cd4ce8 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_plan.tf @@ -0,0 +1,87 @@ +resource "aws_backup_plan" "default" { + count = var.backup_plan_config.enable ? 1 : 0 + name = "${local.resource_name_prefix}-plan" + + dynamic "rule" { + for_each = var.backup_plan_config.rules + content { + recovery_point_tags = { + backup_rule_name = rule.value.name + } + rule_name = rule.value.name + target_vault_name = aws_backup_vault.main.name + schedule = rule.value.schedule + enable_continuous_backup = rule.value.enable_continuous_backup != null ? rule.value.enable_continuous_backup : null + lifecycle { + delete_after = rule.value.lifecycle.delete_after != null ? rule.value.lifecycle.delete_after : null + cold_storage_after = rule.value.lifecycle.cold_storage_after != null ? rule.value.lifecycle.cold_storage_after : null + } + dynamic "copy_action" { + for_each = rule.value.copy_action != null ? rule.value.copy_action : {} + content { + lifecycle { + delete_after = copy_action.value + } + destination_vault_arn = var.backup_copy_vault_arn + } + } + } + } +} + +# this backup plan shouldn't include a continous backup rule as it isn't supported for DynamoDB +resource "aws_backup_plan" "dynamodb" { + count = var.backup_plan_config_dynamodb.enable ? 1 : 0 + name = "${local.resource_name_prefix}-dynamodb-plan" + + dynamic "rule" { + for_each = var.backup_plan_config_dynamodb.rules + content { + recovery_point_tags = { + backup_rule_name = rule.value.name + } + rule_name = rule.value.name + target_vault_name = aws_backup_vault.main.name + schedule = rule.value.schedule + lifecycle { + delete_after = rule.value.lifecycle.delete_after != null ? rule.value.lifecycle.delete_after : null + cold_storage_after = rule.value.lifecycle.cold_storage_after != null ? rule.value.lifecycle.cold_storage_after : null + } + dynamic "copy_action" { + for_each = rule.value.copy_action != null ? rule.value.copy_action : {} + content { + lifecycle { + delete_after = copy_action.value + } + destination_vault_arn = var.backup_copy_vault_arn + } + } + } + } +} + +resource "aws_backup_selection" "default" { + count = var.backup_plan_config.enable ? 1 : 0 + iam_role_arn = aws_iam_role.backup.arn + name = "${local.resource_name_prefix}-selection" + plan_id = aws_backup_plan.default[0].id + + selection_tag { + key = var.backup_plan_config.selection_tag + type = "STRINGEQUALS" + value = "True" + } +} + +resource "aws_backup_selection" "dynamodb" { + count = var.backup_plan_config_dynamodb.enable ? 1 : 0 + iam_role_arn = aws_iam_role.backup.arn + name = "${local.resource_name_prefix}-dynamodb-selection" + plan_id = aws_backup_plan.dynamodb[0].id + + selection_tag { + key = var.backup_plan_config_dynamodb.selection_tag + type = "STRINGEQUALS" + value = "true" + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_report_plan.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_report_plan.tf new file mode 100644 index 000000000..7120bfe70 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_report_plan.tf @@ -0,0 +1,72 @@ +# Create the reports +resource "aws_backup_report_plan" "backup_jobs" { + name = "backup_jobs" + description = "Report for showing whether backups ran successfully in the last 24 hours" + + report_delivery_channel { + formats = [ + "JSON" + ] + s3_bucket_name = var.reports_bucket + s3_key_prefix = "backup_jobs" + } + + report_setting { + report_template = "BACKUP_JOB_REPORT" + } +} + +# Create the restore testing completion reports +resource "aws_backup_report_plan" "backup_restore_testing_jobs" { + name = "backup_restore_testing_jobs" + description = "Report for showing whether backup restore test ran successfully in the last 24 hours" + + report_delivery_channel { + formats = [ + "JSON" + ] + s3_bucket_name = var.reports_bucket + s3_key_prefix = "backup_restore_testing_jobs" + } + + report_setting { + report_template = "RESTORE_JOB_REPORT" + } +} + +resource "aws_backup_report_plan" "resource_compliance" { + name = "resource_compliance" + description = "Report for showing whether resources are compliant with the framework" + + report_delivery_channel { + formats = [ + "JSON" + ] + s3_bucket_name = var.reports_bucket + s3_key_prefix = "resource_compliance" + } + + report_setting { + framework_arns = var.backup_plan_config_dynamodb.enable ? [aws_backup_framework.main.arn, aws_backup_framework.dynamodb[0].arn] : [aws_backup_framework.main.arn] + number_of_frameworks = 2 + report_template = "RESOURCE_COMPLIANCE_REPORT" + } +} + +resource "aws_backup_report_plan" "copy_jobs" { + count = var.backup_plan_config.enable || var.backup_plan_config_dynamodb.enable ? 1 : 0 + name = "copy_jobs" + description = "Report for showing whether copies ran successfully in the last 24 hours" + + report_delivery_channel { + formats = [ + "JSON" + ] + s3_bucket_name = var.reports_bucket + s3_key_prefix = "copy_jobs" + } + + report_setting { + report_template = "COPY_JOB_REPORT" + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_restore_testing.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_restore_testing.tf new file mode 100644 index 000000000..6c4b6f3a9 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_restore_testing.tf @@ -0,0 +1,26 @@ +resource "awscc_backup_restore_testing_plan" "backup_restore_testing_plan" { + restore_testing_plan_name = "backup_restore_testing_plan" + schedule_expression = var.restore_testing_plan_scheduled_expression + start_window_hours = var.restore_testing_plan_start_window + recovery_point_selection = { + algorithm = var.restore_testing_plan_algorithm + include_vaults = [aws_backup_vault.main.arn] + recovery_point_types = var.restore_testing_plan_recovery_point_types + selection_window_days = var.restore_testing_plan_selection_window_days + } +} + +resource "awscc_backup_restore_testing_selection" "backup_restore_testing_selection_dynamodb" { + count = var.backup_plan_config_dynamodb.enable ? 1 : 0 + iam_role_arn = aws_iam_role.backup.arn + protected_resource_type = "DynamoDB" + restore_testing_plan_name = awscc_backup_restore_testing_plan.backup_restore_testing_plan.restore_testing_plan_name + restore_testing_selection_name = "backup_restore_testing_selection_dynamodb" + protected_resource_arns = ["*"] + protected_resource_conditions = { + string_equals = [{ + key = "aws:ResourceTag/${var.backup_plan_config_dynamodb.selection_tag}" + value = "True" + }] + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_vault.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_vault.tf new file mode 100644 index 000000000..49f79ca49 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_vault.tf @@ -0,0 +1,4 @@ +resource "aws_backup_vault" "main" { + name = "${local.resource_name_prefix}-vault" + kms_key_arn = aws_kms_key.aws_backup_key.arn +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/backup_vault_policy.tf b/terraform/account-wide-infrastructure/modules/backup-source/backup_vault_policy.tf new file mode 100644 index 000000000..f1e6222e9 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/backup_vault_policy.tf @@ -0,0 +1,47 @@ +resource "aws_backup_vault_policy" "vault_policy" { + backup_vault_name = aws_backup_vault.main.name + policy = data.aws_iam_policy_document.vault_policy.json +} + +data "aws_iam_policy_document" "vault_policy" { + + + statement { + sid = "DenyApartFromTerraform" + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + condition { + test = "ArnNotEquals" + values = [var.terraform_role_arn] + variable = "aws:PrincipalArn" + } + + actions = [ + "backup:DeleteRecoveryPoint", + "backup:PutBackupVaultAccessPolicy", + "backup:UpdateRecoveryPointLifecycle" + ] + + resources = ["*"] + } + dynamic "statement" { + for_each = var.backup_plan_config.enable || var.backup_plan_config_dynamodb.enable ? [1] : [] + content { + sid = "Allow account to copy into backup vault" + effect = "Allow" + + actions = ["backup:CopyIntoBackupVault"] + resources = ["*"] + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.backup_copy_vault_account_id}:root"] + } + } + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/data.tf b/terraform/account-wide-infrastructure/modules/backup-source/data.tf new file mode 100644 index 000000000..9275ede2e --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/data.tf @@ -0,0 +1,8 @@ +data "aws_caller_identity" "current" {} + +data "aws_region" "current" {} + +data "aws_iam_roles" "roles" { + name_regex = "AWSReservedSSO_Admin_.*" + path_prefix = "/aws-reserved/sso.amazonaws.com/" +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/iam.tf b/terraform/account-wide-infrastructure/modules/backup-source/iam.tf new file mode 100644 index 000000000..e4d58dcc4 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/iam.tf @@ -0,0 +1,37 @@ +data "aws_iam_policy_document" "assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["backup.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +resource "aws_iam_role" "backup" { + name = "${var.project_name}BackupRole" + assume_role_policy = data.aws_iam_policy_document.assume_role.json +} + +resource "aws_iam_role_policy_attachment" "backup" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForBackup" + role = aws_iam_role.backup.name +} + +resource "aws_iam_role_policy_attachment" "restore" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSBackupServiceRolePolicyForRestores" + role = aws_iam_role.backup.name +} + +resource "aws_iam_role_policy_attachment" "s3_restore" { + policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Restore" + role = aws_iam_role.backup.name +} + +resource "aws_iam_role_policy_attachment" "s3_backup" { + policy_arn = "arn:aws:iam::aws:policy/AWSBackupServiceRolePolicyForS3Backup" + role = aws_iam_role.backup.name +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/kms.tf b/terraform/account-wide-infrastructure/modules/backup-source/kms.tf new file mode 100644 index 000000000..55efeeec1 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/kms.tf @@ -0,0 +1,37 @@ +resource "aws_kms_key" "aws_backup_key" { + description = "AWS Backup KMS Key" + deletion_window_in_days = 30 + enable_key_rotation = true + policy = data.aws_iam_policy_document.backup_key_policy.json +} + +resource "aws_kms_alias" "backup_key" { + name = "alias/${var.environment_name}/backup-key" + target_key_id = aws_kms_key.aws_backup_key.key_id +} + +data "aws_iam_policy_document" "backup_key_policy" { + #checkov:skip=CKV_AWS_109:See (CERSS-25168) for more info + #checkov:skip=CKV_AWS_111:See (CERSS-25169) for more info + statement { + sid = "AllowBackupUseOfKey" + principals { + type = "Service" + identifiers = ["backup.amazonaws.com"] + } + actions = ["kms:GenerateDataKey", "kms:Decrypt", "kms:Encrypt"] + resources = ["*"] + } + statement { + sid = "EnableIAMUserPermissions" + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root", + var.terraform_role_arn + ] + } + actions = ["kms:*"] + resources = ["*"] + } +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/locals.tf b/terraform/account-wide-infrastructure/modules/backup-source/locals.tf new file mode 100644 index 000000000..e6929817b --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/locals.tf @@ -0,0 +1,3 @@ +locals { + resource_name_prefix = "${data.aws_region.current.name}-${data.aws_caller_identity.current.account_id}-backup" +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/sns.tf b/terraform/account-wide-infrastructure/modules/backup-source/sns.tf new file mode 100644 index 000000000..f91b26b96 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/sns.tf @@ -0,0 +1,34 @@ +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" { + policy_id = "backup" + + statement { + actions = [ + "SNS:Publish", + ] + + effect = "Allow" + + principals { + type = "Service" + identifiers = ["backup.amazonaws.com"] + } + + resources = ["*"] + + sid = "allow_backup" + } +} + +resource "aws_sns_topic_subscription" "aws_backup_notifications_email_target" { + for_each = var.notification_target_email_addresses + topic_arn = aws_sns_topic.backup.arn + protocol = "email" + endpoint = each.value + filter_policy = jsonencode({ "State" : [{ "anything-but" : "COMPLETED" }] }) +} diff --git a/terraform/account-wide-infrastructure/modules/backup-source/variables.tf b/terraform/account-wide-infrastructure/modules/backup-source/variables.tf new file mode 100644 index 000000000..72cc612f6 --- /dev/null +++ b/terraform/account-wide-infrastructure/modules/backup-source/variables.tf @@ -0,0 +1,114 @@ +variable "project_name" { + description = "The name of the project this relates to." + type = string +} + +variable "environment_name" { + description = "The name of the environment where AWS Backup is configured." + type = string +} + +variable "notification_target_email_addresses" { + description = "The email addresses to which backup notifications will be sent via SNS." + type = set(string) + default = [] +} + +variable "bootstrap_kms_key_arn" { + description = "The ARN of the bootstrap KMS key used for encryption at rest of the SNS topic." + type = string +} + +variable "reports_bucket" { + description = "Bucket to drop backup reports into" + type = string +} + +variable "terraform_role_arn" { + description = "ARN of Terraform role used to deploy to account" + type = string +} + +variable "restore_testing_plan_algorithm" { + description = "Algorithm of the Recovery Selection Point" + type = string + default = "LATEST_WITHIN_WINDOW" +} + +variable "restore_testing_plan_start_window" { + description = "Start window from the scheduled time during which the test should start" + type = number + default = 1 +} + +variable "restore_testing_plan_scheduled_expression" { + description = "Scheduled Expression of Recovery Selection Point" + type = string + default = "cron(0 1 ? * SUN *)" +} + +variable "restore_testing_plan_recovery_point_types" { + description = "Recovery Point Types" + type = list(string) + default = ["SNAPSHOT"] +} + +variable "restore_testing_plan_selection_window_days" { + description = "Selection window days" + type = number + default = 7 +} + +variable "backup_copy_vault_arn" { + description = "The ARN of the destination backup vault for cross-account backup copies." + type = string + default = "" +} + +variable "backup_copy_vault_account_id" { + description = "The account id of the destination backup vault for allowing restores back into the source account." + type = string + default = "" +} + +variable "backup_plan_config" { + description = "Configuration for backup plans" + type = object({ + enable = bool + selection_tag = string + compliance_resource_types = list(string) + rules = list(object({ + name = string + schedule = string + enable_continuous_backup = optional(bool) + lifecycle = object({ + delete_after = optional(number) + cold_storage_after = optional(number) + }) + copy_action = optional(object({ + delete_after = optional(number) + })) + })) + }) +} +variable "backup_plan_config_dynamodb" { + description = "Configuration for backup plans with dynamodb" + type = object({ + enable = bool + selection_tag = string + compliance_resource_types = list(string) + rules = optional(list(object({ + name = string + schedule = string + enable_continuous_backup = optional(bool) + lifecycle = object({ + delete_after = number + cold_storage_after = optional(number) + }) + copy_action = optional(object({ + delete_after = optional(number) + })) + }))) + }) + +} diff --git a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/secretsmanager.tf b/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/secretsmanager.tf deleted file mode 100644 index 984bd41e3..000000000 --- a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/secretsmanager.tf +++ /dev/null @@ -1,8 +0,0 @@ -data "aws_secretsmanager_secret" "emails" { - name = "${var.name_prefix}-emails" -} - -data "aws_secretsmanager_secret_version" "emails" { - secret_id = data.aws_secretsmanager_secret.emails.id - -} diff --git a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/sns.tf b/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/sns.tf index 011568f53..5abaa0a6c 100644 --- a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/sns.tf +++ b/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/sns.tf @@ -4,7 +4,7 @@ resource "aws_sns_topic" "sns_topic" { } resource "aws_sns_topic_subscription" "sns_subscription" { - for_each = nonsensitive(toset(tolist(jsondecode(data.aws_secretsmanager_secret_version.emails.secret_string)))) + for_each = var.notification_emails topic_arn = aws_sns_topic.sns_topic.arn protocol = "email" endpoint = sensitive(each.value) diff --git a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/vars.tf b/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/vars.tf index a244243e1..605569262 100644 --- a/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/vars.tf +++ b/terraform/account-wide-infrastructure/modules/lambda-errors-metric-alarm/vars.tf @@ -25,3 +25,9 @@ variable "kms_deletion_window_in_days" { description = "The duration in days after which the key is deleted after destruction of the resource." default = 7 } + +variable "notification_emails" { + type = set(string) + description = "The email addresses to which notifications will be sent." + default = [] +} diff --git a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf index ad37cff7d..06e61a58e 100644 --- a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf +++ b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/s3.tf @@ -3,8 +3,9 @@ resource "aws_s3_bucket" "authorization-store" { force_destroy = var.enable_bucket_force_destroy tags = { - Name = "authorization store" - Environment = "${var.name_prefix}" + Name = "authorization store" + Environment = "${var.name_prefix}" + NHSE-Enable-S3-Backup = "${var.enable_backups}" } } @@ -27,6 +28,32 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "authorization-sto } } +resource "aws_s3_bucket_policy" "authorization_store_bucket_policy" { + bucket = aws_s3_bucket.authorization-store.id + + policy = jsonencode({ + Version = "2012-10-17" + Id = "authorization_store_bucket_policy" + Statement = [ + { + Sid = "HTTPSOnly" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.authorization-store.arn, + "${aws_s3_bucket.authorization-store.arn}/*", + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + ] + }) +} + resource "aws_s3_bucket_versioning" "authorization-store" { bucket = aws_s3_bucket.authorization-store.id versioning_configuration { diff --git a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/vars.tf b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/vars.tf index f593893ae..4a4db27b6 100644 --- a/terraform/account-wide-infrastructure/modules/permissions-store-bucket/vars.tf +++ b/terraform/account-wide-infrastructure/modules/permissions-store-bucket/vars.tf @@ -8,3 +8,9 @@ variable "enable_bucket_force_destroy" { description = "A boolean flag to enable force destroy of the S3 bucket, so that all objects in the bucket are deleted when the bucket is destroyed." default = false } + +variable "enable_backups" { + type = bool + description = "enable AWS cloud backups" + default = false +} diff --git a/terraform/account-wide-infrastructure/modules/pointers-table/dynamodb.tf b/terraform/account-wide-infrastructure/modules/pointers-table/dynamodb.tf index 1da659046..93e060fdb 100644 --- a/terraform/account-wide-infrastructure/modules/pointers-table/dynamodb.tf +++ b/terraform/account-wide-infrastructure/modules/pointers-table/dynamodb.tf @@ -51,4 +51,6 @@ resource "aws_dynamodb_table" "pointers" { point_in_time_recovery { enabled = var.enable_pitr } + + tags = { NHSE-Enable-DDB-Backup = "${var.enable_backups}" } } diff --git a/terraform/account-wide-infrastructure/modules/pointers-table/vars.tf b/terraform/account-wide-infrastructure/modules/pointers-table/vars.tf index 738e3b99e..29d04b60e 100644 --- a/terraform/account-wide-infrastructure/modules/pointers-table/vars.tf +++ b/terraform/account-wide-infrastructure/modules/pointers-table/vars.tf @@ -20,3 +20,9 @@ variable "kms_deletion_window_in_days" { description = "The duration in days after which the key is deleted after destruction of the resource." default = 7 } + +variable "enable_backups" { + type = bool + description = "Enable AwS cloud backup" + default = false +} diff --git a/terraform/account-wide-infrastructure/modules/truststore-bucket/s3.tf b/terraform/account-wide-infrastructure/modules/truststore-bucket/s3.tf index 6767ecaa5..aa32f2f16 100644 --- a/terraform/account-wide-infrastructure/modules/truststore-bucket/s3.tf +++ b/terraform/account-wide-infrastructure/modules/truststore-bucket/s3.tf @@ -1,6 +1,7 @@ resource "aws_s3_bucket" "api_truststore" { bucket = "${var.name_prefix}-api-truststore" force_destroy = var.enable_bucket_force_destroy + tags = { NHSE-Enable-S3-Backup = "${var.enable_backups}" } } resource "aws_s3_bucket_policy" "api_truststore_bucket_policy" { diff --git a/terraform/account-wide-infrastructure/modules/truststore-bucket/vars.tf b/terraform/account-wide-infrastructure/modules/truststore-bucket/vars.tf index 3c6fa8790..e3b2f6f43 100644 --- a/terraform/account-wide-infrastructure/modules/truststore-bucket/vars.tf +++ b/terraform/account-wide-infrastructure/modules/truststore-bucket/vars.tf @@ -13,3 +13,9 @@ variable "enable_bucket_force_destroy" { description = "A boolean flag to enable force destroy of the S3 bucket, so that all objects in the bucket are deleted when the bucket is destroyed." default = false } + +variable "enable_backups" { + type = bool + description = "enable AWS cloud backups" + default = false +} diff --git a/terraform/backup-infrastructure/README.md b/terraform/backup-infrastructure/README.md new file mode 100644 index 000000000..8af8ae7db --- /dev/null +++ b/terraform/backup-infrastructure/README.md @@ -0,0 +1,87 @@ +# NRLF Backup Infrastructure + +This directory contains AWS backup terraform resources which are global to a given account. + +Each subdirectory corresponds to each AWS account (`prod` and `test`). + +**Backup infrastructure should be deployed manually and not be run as part of CI.** + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Initialise shell environment](#initialise-shell-environment) +3. [Deploy backup resources](#deploy-backup-resources) +4. [Tear down backup resources](#tear-down-backup-resources) + +## Prerequisites + +Before deploying the NRLF backup infrastructure, you will need: + +- An AWS backup account that have already been bootstrapped, as described in [bootstrap/README.md](../bootstrap/README.md). This is a one-time account setup step. + +## Deploy backup resources + +To deploy the backup resources, first login to the AWS mgmt account on the CLI. + +Then, initialise the terraform backup workspace. For the test account: + +```shell +$ cd test +$ terraform init && ( \ + terraform workspace new backup-infra-test || \ + terraform workspace select backup-infra-test ) +``` + +If you want to apply changes to prod, use the `prod` directory and the `backup-infra-prod` terraform workspace. + +Once you have your workspace set, you can plan your changes with: + +```shell +$ terraform plan \ + -var 'source_account_id=SOURCE_ACCOUNT_ID" \ + -var 'assume_account=AWS_ACCOUNT_ID' \ + -var 'assume_role=terraform' +``` + +Replacing SOURCE_ACCOUNT with the account id that will be sending backups to the backup account and AWS_ACCOUNT_ID with the AWS account id of your backup account. + +Once you're happy with your planned changes, you can apply them with: + +```shell +$ terraform apply \ + -var 'source_account_id=SOURCE_ACCOUNT_ID" \ + -var 'assume_account=AWS_ACCOUNT_ID' \ + -var 'assume_role=terraform' +``` + +Replacing SOURCE_ACCOUNT with the account id that will be sending backups to the backup account and AWS_ACCOUNT_ID with the AWS account id of your backup account. + +## Tear down backup resources + +WARNING - This action will destroy all backup resources from the AWS account. This should +only be done if you are sure that this is safe and are sure that you are signed into the correct +AWS account. + +To tear down backup resources, first login to the AWS mgmt account on the CLI. + +Then, initialise your terraform workspace. For the test account: + +```shell +$ cd test +$ terraform init && ( \ + terraform workspace new backup-infra-test || \ + terraform workspace select backup-infra-test ) +``` + +If you want to destroy resources in prod, use the `prod` directory and the `backup-infra-prod` terraform workspace. + +And then, to tear down: + +```shell +$ terraform destroy \ + -var 'source_account_id=SOURCE_ACCOUNT_ID" \ + -var 'assume_account=AWS_ACCOUNT_ID' \ + -var 'assume_role=terraform' +``` + +Replacing SOURCE_ACCOUNT with the account id that will be sending backups to the backup account and AWS_ACCOUNT_ID with the AWS account id of your backup account. diff --git a/terraform/backup-infrastructure/modules/aws-backup-destination/README.md b/terraform/backup-infrastructure/modules/aws-backup-destination/README.md new file mode 100644 index 000000000..10e01514b --- /dev/null +++ b/terraform/backup-infrastructure/modules/aws-backup-destination/README.md @@ -0,0 +1,31 @@ +# AWS Backup Module + +The AWS Backup Module helps automates the setup of AWS Backup resources in a destination account. It streamlines the process of creating, managing, and standardising backup configurations. + +## Inputs + +| Name | Description | Type | Default | Required | +| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------- | :------: | +| [account_id](#input_account_id) | The id of the account that the vault will be in | `string` | n/a | yes | +| [changeable_for_days](#input_changeable_for_days) | How long you want the vault lock to be changeable for, only applies to compliance mode. This value is expressed in days no less than 3 and no greater than 36,500; otherwise, an error will return. | `number` | `14` | no | +| [enable_vault_protection](#input_enable_vault_protection) | Flag which controls if the vault lock is enabled | `bool` | `false` | no | +| [kms_key](#input_kms_key) | The KMS key used to secure the vault | `string` | n/a | yes | +| [region](#input_region) | The region we should be operating in | `string` | `"eu-west-2"` | no | +| [source_account_id](#input_source_account_id) | The id of the account that backups will come from | `string` | n/a | yes | +| [source_account_name](#input_source_account_name) | The name of the account that backups will come from | `string` | n/a | yes | +| [vault_lock_max_retention_days](#input_vault_lock_max_retention_days) | The maximum retention period that the vault retains its recovery points | `number` | `365` | no | +| [vault_lock_min_retention_days](#input_vault_lock_min_retention_days) | The minimum retention period that the vault retains its recovery points | `number` | `365` | no | +| [vault_lock_type](#input_vault_lock_type) | The type of lock that the vault should be, will default to governance | `string` | `"governance"` | no | + +## Example + +```terraform +module "test_backup_vault" { + source = "./modules/aws_backup" + source_account_name = "test" + account_id = local.aws_accounts_ids["backup"] + source_account_id = local.aws_accounts_ids["test"] + kms_key = aws_kms_key.backup_key.arn + enable_vault_protection = true +} +``` diff --git a/terraform/backup-infrastructure/modules/aws-backup-destination/backup.tf b/terraform/backup-infrastructure/modules/aws-backup-destination/backup.tf new file mode 100644 index 000000000..1df7f2acf --- /dev/null +++ b/terraform/backup-infrastructure/modules/aws-backup-destination/backup.tf @@ -0,0 +1,8 @@ +resource "aws_backup_vault" "vault" { + name = "${var.source_account_name}-backup-vault" + kms_key_arn = var.kms_key +} + +output "vault_arn" { + value = aws_backup_vault.vault.arn +} diff --git a/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_lock.tf b/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_lock.tf new file mode 100644 index 000000000..e1a31781e --- /dev/null +++ b/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_lock.tf @@ -0,0 +1,7 @@ +resource "aws_backup_vault_lock_configuration" "vault_lock" { + count = var.enable_vault_protection ? 1 : 0 + backup_vault_name = aws_backup_vault.vault.name + changeable_for_days = var.vault_lock_type == "compliance" ? var.changeable_for_days : null + max_retention_days = var.vault_lock_max_retention_days + min_retention_days = var.vault_lock_min_retention_days +} diff --git a/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_policy.tf b/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_policy.tf new file mode 100644 index 000000000..224904193 --- /dev/null +++ b/terraform/backup-infrastructure/modules/aws-backup-destination/backup_vault_policy.tf @@ -0,0 +1,68 @@ +resource "aws_backup_vault_policy" "vault_policy" { + backup_vault_name = aws_backup_vault.vault.name + policy = data.aws_iam_policy_document.vault_policy.json +} + +data "aws_iam_policy_document" "vault_policy" { + + statement { + sid = "AllowCopyToVault" + effect = "Allow" + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.source_account_id}:root"] + } + + actions = [ + "backup:CopyIntoBackupVault" + ] + resources = ["*"] + } + + dynamic "statement" { + for_each = var.enable_vault_protection ? [1] : [] + content { + sid = "DenyBackupVaultAccess" + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + actions = [ + "backup:DeleteRecoveryPoint", + "backup:PutBackupVaultAccessPolicy", + "backup:UpdateRecoveryPointLifecycle", + "backup:DeleteBackupVault", + "backup:StartRestoreJob", + "backup:DeleteBackupVaultLockConfiguration", + ] + resources = ["*"] + } + } + + dynamic "statement" { + for_each = var.enable_vault_protection ? [1] : [] + content { + sid = "DenyBackupCopyExceptToSourceAccount" + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["arn:aws:iam::${var.account_id}:root"] + } + actions = [ + "backup:CopyFromBackupVault" + ] + resources = ["*"] + condition { + test = "StringNotEquals" + variable = "backup:CopyTargets" + values = [ + "arn:aws:backup:${var.region}:${var.source_account_id}:backup-vault:${var.region}-${var.source_account_id}-backup-vault" + ] + } + } + } +} diff --git a/terraform/backup-infrastructure/modules/aws-backup-destination/variables.tf b/terraform/backup-infrastructure/modules/aws-backup-destination/variables.tf new file mode 100644 index 000000000..75e620cfa --- /dev/null +++ b/terraform/backup-infrastructure/modules/aws-backup-destination/variables.tf @@ -0,0 +1,67 @@ +variable "source_account_name" { + # This is used as a prefix for the vault name, and referenced by the policy and the lock. + # It doesn't have to match anything in the source AWS account. + description = "The name of the account that backups will come from" + type = string +} + +variable "source_account_id" { + # The source account ID is used in the policy to allow permit root in the source account + # to copy backups into the vault. + description = "The id of the account that backups will come from" + type = string +} + +variable "account_id" { + # This is used to deny root from being able to copy backups from the vault + # to anywhere other than the source account. The constraint will need to + # be removed if the original source account is lost. + description = "The id of the account that the vault will be in" + type = string +} + +variable "region" { + description = "The region we should be operating in" + type = string + default = "eu-west-2" +} + +variable "kms_key" { + description = "The KMS key used to secure the vault" + type = string +} + +variable "enable_vault_protection" { + # With this set to true, privileges are locked down so that the vault can't be deleted or + # have its policy changed. The minimum and maximum retention periods are also set only if this is true. + description = "Flag which controls if the vault lock is enabled" + type = bool + default = false +} + +variable "vault_lock_type" { + description = "The type of lock that the vault should be, will default to governance" + type = string + # See toplevel README.md: + # DO NOT SET THIS TO compliance UNTIL YOU ARE SURE THAT YOU WANT TO LOCK THE VAULT PERMANENTLY + # When you do, you will also need to set "enable_vault_protection" to true for it to take effect. + default = "governance" +} + +variable "vault_lock_min_retention_days" { + description = "The minimum retention period that the vault retains its recovery points" + type = number + default = 365 +} + +variable "vault_lock_max_retention_days" { + description = "The maximum retention period that the vault retains its recovery points" + type = number + default = 365 +} + +variable "changeable_for_days" { + description = "How long you want the vault lock to be changeable for, only applies to compliance mode. This value is expressed in days no less than 3 and no greater than 36,500; otherwise, an error will return." + type = number + default = 14 +} diff --git a/terraform/backup-infrastructure/test/aws-backup.tf b/terraform/backup-infrastructure/test/aws-backup.tf new file mode 100644 index 000000000..19ee2e43a --- /dev/null +++ b/terraform/backup-infrastructure/test/aws-backup.tf @@ -0,0 +1,42 @@ + +# We need a key for the backup vaults. This key will be used to encrypt the backups themselves. +# We need one per vault (on the assumption that each vault will be in a different account). +resource "aws_kms_key" "destination_backup_key" { + description = "KMS key for AWS Backup vaults" + 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::${local.destination_account_id}:root" + } + Action = "kms:*" + Resource = "*" + } + ] + }) +} + +module "destination" { + source = "../modules/aws-backup-destination" + + source_account_name = "test" # please note that the assigned value would be the prefix in aws_backup_vault.vault.name + account_id = local.destination_account_id + source_account_id = local.source_account_id + kms_key = aws_kms_key.destination_backup_key.arn + enable_vault_protection = false +} + +### +# Destination vault ARN output +### + +output "destination_vault_arn" { + # The ARN of the backup vault in the destination account is needed by + # the source account to copy backups into it. + value = module.destination.vault_arn +} diff --git a/terraform/backup-infrastructure/test/data.tf b/terraform/backup-infrastructure/test/data.tf new file mode 100644 index 000000000..8fc4b38cc --- /dev/null +++ b/terraform/backup-infrastructure/test/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/terraform/backup-infrastructure/test/locals.tf b/terraform/backup-infrastructure/test/locals.tf new file mode 100644 index 000000000..6bc51d571 --- /dev/null +++ b/terraform/backup-infrastructure/test/locals.tf @@ -0,0 +1,8 @@ +locals { + # Adjust these as required + project_name = "nrlf-test-backup" + environment_name = "test" + + source_account_id = var.source_account_id + destination_account_id = var.assume_account +} diff --git a/terraform/backup-infrastructure/test/main.tf b/terraform/backup-infrastructure/test/main.tf new file mode 100644 index 000000000..260e66173 --- /dev/null +++ b/terraform/backup-infrastructure/test/main.tf @@ -0,0 +1,32 @@ +provider "aws" { + region = "eu-west-2" + + assume_role { + role_arn = "arn:aws:iam::${var.assume_account}:role/${var.assume_role}" + } + + default_tags { + tags = { + project_name = local.project_name + workspace = terraform.workspace + } + } +} + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.76.0" + } + } + + backend "s3" { + region = "eu-west-2" + bucket = "nhsd-nrlf--terraform-state" + dynamodb_table = "nhsd-nrlf--terraform-state-lock" + key = "terraform-state-dev-backup-infrastructure" + workspace_key_prefix = "nhsd-nrlf" + encrypt = false + } +} diff --git a/terraform/backup-infrastructure/test/vars.tf b/terraform/backup-infrastructure/test/vars.tf new file mode 100644 index 000000000..e091ee9c5 --- /dev/null +++ b/terraform/backup-infrastructure/test/vars.tf @@ -0,0 +1,15 @@ +variable "assume_account" { + description = "The account id to deploy the infrastructure to" + sensitive = true +} + +variable "assume_role" { + description = "Name of the role to assume to deploy the infrastructure" + type = string +} + +variable "source_account_id" { + description = "The account id of the backup source account" + type = string + sensitive = true +}
"SNAPSHOT"
]